pytrends-ng 0.1.0__tar.gz

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.
@@ -0,0 +1,2 @@
1
+ .secrets/
2
+ __pycache__/
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: pytrends-ng
3
+ Version: 0.1.0
4
+ Summary: Drop-in replacement for the archived pytrends: Google Trends without 429 errors, unlimited keywords on one scale.
5
+ Project-URL: Google Trends actor, https://apify.com/githubanassk/google-trends-scraper
6
+ License-Expression: MIT
7
+ Keywords: google trends,google trends api,keyword research,pytrends,seo,trends
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Internet :: WWW/HTTP
11
+ Requires-Python: >=3.8
12
+ Requires-Dist: pandas>=1.1
13
+ Requires-Dist: requests>=2.20
14
+ Description-Content-Type: text/markdown
15
+
16
+ # pytrends-ng: pytrends without the 429 errors
17
+
18
+ [pytrends](https://github.com/GeneralMills/pytrends) was archived in April 2025 and its last release is from 2023. Google answers it with `429 Too Many Requests` most of the time. `pytrends-ng` keeps the same calls, so your code works again after changing one import:
19
+
20
+ ```python
21
+ # from pytrends.request import TrendReq
22
+ from pytrends_ng import TrendReq
23
+
24
+ pytrends = TrendReq() # reads APIFY_TOKEN from the environment
25
+ pytrends.build_payload(["chatgpt", "claude", "gemini"], timeframe="today 3-m", geo="US")
26
+
27
+ pytrends.interest_over_time() # DataFrame: date index, one column per keyword, isPartial
28
+ pytrends.interest_by_region() # DataFrame: one row per region
29
+ pytrends.related_queries() # {keyword: {"top": DataFrame, "rising": DataFrame}}
30
+ pytrends.trending_searches(pn="united_states")
31
+ ```
32
+
33
+ ## How it works
34
+
35
+ The requests run on a hosted [Google Trends actor](https://apify.com/githubanassk/google-trends-scraper) on Apify, which rotates IPs and retries rate limits for you. No proxies to buy, no sleep loops.
36
+
37
+ 1. Create a free Apify account. It includes $5 of usage every month.
38
+ 2. Copy your API token from [Settings > Integrations](https://console.apify.com/settings/integrations).
39
+ 3. `pip install pytrends-ng` and `export APIFY_TOKEN=...`
40
+
41
+ Pricing is $2 per 1,000 results, where one result is one keyword in one country with its full timeline, regions and related queries. A 5-keyword payload costs $0.01, so the free credit covers about 2,500 keywords a month.
42
+
43
+ ## Better than the original
44
+
45
+ - **More than 5 keywords.** `build_payload` takes any number of keywords and puts them all on one shared 0-100 scale. Google's own limit is 5.
46
+ - **Small keywords stay accurate.** Keywords far smaller than the biggest one keep decimal values instead of rounding to 0.
47
+ - **One request per payload.** `interest_over_time`, `interest_by_region` and `related_queries` all read from the same run.
48
+
49
+ ## Supported calls
50
+
51
+ | pytrends call | Status |
52
+ |---|---|
53
+ | `build_payload(kw_list, cat, timeframe, geo, gprop)` | Yes |
54
+ | `interest_over_time()` | Yes |
55
+ | `interest_by_region(resolution, inc_low_vol, inc_geo_code)` | COUNTRY, REGION, DMA (not CITY) |
56
+ | `related_queries()` | Yes. `value` is numeric; `formattedValue` keeps Google's text such as `Breakout` |
57
+ | `trending_searches(pn)`, `today_searches(pn)`, `realtime_trending_searches(pn)` | Yes |
58
+ | `related_topics()`, `suggestions()` | Not yet |
59
+
60
+ Constructor arguments such as `hl`, `tz`, `proxies` and `retries` are accepted and ignored, so existing code does not break.
61
+
62
+ ## License
63
+
64
+ MIT. Not affiliated with Google or the original pytrends authors.
@@ -0,0 +1,49 @@
1
+ # pytrends-ng: pytrends without the 429 errors
2
+
3
+ [pytrends](https://github.com/GeneralMills/pytrends) was archived in April 2025 and its last release is from 2023. Google answers it with `429 Too Many Requests` most of the time. `pytrends-ng` keeps the same calls, so your code works again after changing one import:
4
+
5
+ ```python
6
+ # from pytrends.request import TrendReq
7
+ from pytrends_ng import TrendReq
8
+
9
+ pytrends = TrendReq() # reads APIFY_TOKEN from the environment
10
+ pytrends.build_payload(["chatgpt", "claude", "gemini"], timeframe="today 3-m", geo="US")
11
+
12
+ pytrends.interest_over_time() # DataFrame: date index, one column per keyword, isPartial
13
+ pytrends.interest_by_region() # DataFrame: one row per region
14
+ pytrends.related_queries() # {keyword: {"top": DataFrame, "rising": DataFrame}}
15
+ pytrends.trending_searches(pn="united_states")
16
+ ```
17
+
18
+ ## How it works
19
+
20
+ The requests run on a hosted [Google Trends actor](https://apify.com/githubanassk/google-trends-scraper) on Apify, which rotates IPs and retries rate limits for you. No proxies to buy, no sleep loops.
21
+
22
+ 1. Create a free Apify account. It includes $5 of usage every month.
23
+ 2. Copy your API token from [Settings > Integrations](https://console.apify.com/settings/integrations).
24
+ 3. `pip install pytrends-ng` and `export APIFY_TOKEN=...`
25
+
26
+ Pricing is $2 per 1,000 results, where one result is one keyword in one country with its full timeline, regions and related queries. A 5-keyword payload costs $0.01, so the free credit covers about 2,500 keywords a month.
27
+
28
+ ## Better than the original
29
+
30
+ - **More than 5 keywords.** `build_payload` takes any number of keywords and puts them all on one shared 0-100 scale. Google's own limit is 5.
31
+ - **Small keywords stay accurate.** Keywords far smaller than the biggest one keep decimal values instead of rounding to 0.
32
+ - **One request per payload.** `interest_over_time`, `interest_by_region` and `related_queries` all read from the same run.
33
+
34
+ ## Supported calls
35
+
36
+ | pytrends call | Status |
37
+ |---|---|
38
+ | `build_payload(kw_list, cat, timeframe, geo, gprop)` | Yes |
39
+ | `interest_over_time()` | Yes |
40
+ | `interest_by_region(resolution, inc_low_vol, inc_geo_code)` | COUNTRY, REGION, DMA (not CITY) |
41
+ | `related_queries()` | Yes. `value` is numeric; `formattedValue` keeps Google's text such as `Breakout` |
42
+ | `trending_searches(pn)`, `today_searches(pn)`, `realtime_trending_searches(pn)` | Yes |
43
+ | `related_topics()`, `suggestions()` | Not yet |
44
+
45
+ Constructor arguments such as `hl`, `tz`, `proxies` and `retries` are accepted and ignored, so existing code does not break.
46
+
47
+ ## License
48
+
49
+ MIT. Not affiliated with Google or the original pytrends authors.
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pytrends-ng"
7
+ dynamic = ["version"]
8
+ description = "Drop-in replacement for the archived pytrends: Google Trends without 429 errors, unlimited keywords on one scale."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = "MIT"
12
+ dependencies = ["pandas>=1.1", "requests>=2.20"]
13
+ keywords = ["pytrends", "google trends", "google trends api", "trends", "seo", "keyword research"]
14
+ classifiers = ["Programming Language :: Python :: 3", "Topic :: Internet :: WWW/HTTP", "Intended Audience :: Developers"]
15
+
16
+ [project.urls]
17
+ "Google Trends actor" = "https://apify.com/githubanassk/google-trends-scraper"
18
+
19
+ [tool.hatch.version]
20
+ path = "pytrends_ng/__init__.py"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["pytrends_ng"]
@@ -0,0 +1,155 @@
1
+ """Drop-in replacement for the archived pytrends library.
2
+
3
+ Same calls as pytrends (TrendReq, build_payload, interest_over_time, ...), but the
4
+ data comes from a hosted Google Trends actor on Apify, so there are no 429 errors
5
+ to handle on your side. Needs an Apify API token (free account, $5 monthly credit).
6
+ """
7
+ import os
8
+ import re
9
+
10
+ import pandas as pd
11
+ import requests
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = ["TrendReq", "TrendsError"]
15
+
16
+ ACTOR = "githubanassk~google-trends-scraper"
17
+ API = "https://api.apify.com/v2/acts/{actor}/run-sync-get-dataset-items"
18
+
19
+ # pytrends' trending_searches(pn=...) takes country names; the actor takes ISO codes.
20
+ # ponytail: common countries only; any two-letter code also works.
21
+ COUNTRY_CODES = {
22
+ "united_states": "US", "united_kingdom": "GB", "canada": "CA", "australia": "AU",
23
+ "india": "IN", "germany": "DE", "france": "FR", "japan": "JP", "brazil": "BR",
24
+ "mexico": "MX", "spain": "ES", "italy": "IT", "netherlands": "NL", "south_korea": "KR",
25
+ "indonesia": "ID", "turkey": "TR", "poland": "PL", "south_africa": "ZA", "nigeria": "NG",
26
+ "philippines": "PH", "singapore": "SG", "sweden": "SE", "argentina": "AR",
27
+ }
28
+ GPROP = {"": "", "images": "images", "news": "news", "youtube": "youtube", "froogle": "froogle"}
29
+
30
+
31
+ class TrendsError(Exception):
32
+ pass
33
+
34
+
35
+ def _number(formatted):
36
+ """'+3,800%' -> 3800, '100' -> 100, 'Breakout' -> None (see formattedValue)."""
37
+ digits = re.sub(r"[^\d]", "", str(formatted))
38
+ return int(digits) if digits else None
39
+
40
+
41
+ class TrendReq:
42
+ def __init__(self, hl="en-US", tz=360, geo="", timeout=None, proxies=None, retries=0,
43
+ backoff_factor=0, requests_args=None, apify_token=None, actor=ACTOR):
44
+ # hl, tz, timeout, proxies, retries, backoff_factor and requests_args are
45
+ # accepted for pytrends compatibility; the actor handles proxies and retries.
46
+ token = apify_token or os.environ.get("APIFY_TOKEN")
47
+ if not token:
48
+ raise TrendsError("Set APIFY_TOKEN or pass apify_token=... "
49
+ "(free at https://console.apify.com/settings/integrations).")
50
+ self._token, self._url = token, API.format(actor=actor.replace("/", "~"))
51
+ self._timeout = timeout if isinstance(timeout, (int, float)) else 300
52
+ self.geo = geo
53
+ self.kw_list, self.cat, self.timeframe, self.gprop = [], 0, "today 5-y", ""
54
+ self._runs = {}
55
+
56
+ def build_payload(self, kw_list, cat=0, timeframe="today 5-y", geo="", gprop=""):
57
+ if gprop not in GPROP:
58
+ raise ValueError("gprop must be empty or one of images, news, youtube, froogle")
59
+ self.kw_list, self.cat, self.timeframe, self.gprop = list(kw_list), cat, timeframe, gprop
60
+ self.geo = geo or self.geo
61
+ self._runs = {} # new payload, new data
62
+
63
+ def _run(self, run_input):
64
+ """One HTTP call: start the actor, wait for it, return its rows."""
65
+ resp = requests.post(self._url, json=run_input, timeout=self._timeout,
66
+ headers={"Authorization": f"Bearer {self._token}"})
67
+ if resp.status_code >= 300:
68
+ try:
69
+ msg = resp.json()["error"]["message"]
70
+ except (ValueError, KeyError, TypeError):
71
+ msg = resp.text[:300]
72
+ raise TrendsError(f"Google Trends request failed ({resp.status_code}): {msg}")
73
+ return resp.json()
74
+
75
+ def _keywords(self, region_level=""):
76
+ """One actor run per payload (and per region level); every method reads from it."""
77
+ if not self.kw_list:
78
+ raise TrendsError("Call build_payload(kw_list=[...]) first.")
79
+ if region_level not in self._runs:
80
+ rows = self._run({
81
+ "keywords": self.kw_list, "geos": [self.geo] if self.geo else [],
82
+ "timeframe": self.timeframe, "category": self.cat,
83
+ "searchProperty": self.gprop, "regionLevel": region_level,
84
+ "includeRelatedQueries": True,
85
+ })
86
+ self._runs[region_level] = {r["keyword"]: r for r in rows if r.get("type") == "keyword"}
87
+ return self._runs[region_level]
88
+
89
+ def interest_over_time(self):
90
+ """DataFrame indexed by date, one column per keyword (shared 0-100 scale), plus isPartial."""
91
+ rows = self._keywords()
92
+ df = pd.DataFrame({
93
+ kw: pd.Series({pd.Timestamp(p["date"]).tz_localize(None): p["value"] for p in rows[kw]["timeline"]})
94
+ for kw in self.kw_list if kw in rows
95
+ })
96
+ if df.empty:
97
+ return df
98
+ df.index.name = "date"
99
+ df["isPartial"] = False
100
+ return df
101
+
102
+ def interest_by_region(self, resolution="COUNTRY", inc_low_vol=True, inc_geo_code=False):
103
+ # Worldwide + COUNTRY is the actor's automatic level; a country geo needs an explicit level.
104
+ level = "" if resolution == "COUNTRY" and not self.geo else resolution.upper()
105
+ if level == "CITY":
106
+ raise TrendsError("City resolution is not supported; use REGION or DMA.")
107
+ rows = self._keywords(level)
108
+ frames = {}
109
+ for kw in self.kw_list:
110
+ regions = (rows.get(kw) or {}).get("interestByRegion") or []
111
+ frames[kw] = pd.Series({r["geoName"]: r["value"] for r in regions})
112
+ df = pd.DataFrame(frames)
113
+ df.index.name = "geoName"
114
+ if not inc_low_vol:
115
+ df = df[(df.fillna(0) > 0).any(axis=1)]
116
+ if inc_geo_code:
117
+ codes = {r["geoName"]: r["geoCode"] for kw in self.kw_list
118
+ for r in (rows.get(kw) or {}).get("interestByRegion") or []}
119
+ df.insert(0, "geoCode", df.index.map(codes))
120
+ return df
121
+
122
+ def related_queries(self):
123
+ """{keyword: {"top": DataFrame, "rising": DataFrame}} like pytrends."""
124
+ rows = self._keywords()
125
+ out = {}
126
+ for kw in self.kw_list:
127
+ r = rows.get(kw) or {}
128
+ out[kw] = {}
129
+ for key, field in (("top", "relatedQueriesTop"), ("rising", "relatedQueriesRising")):
130
+ items = r.get(field) or []
131
+ out[kw][key] = pd.DataFrame(
132
+ [{"query": q["query"], "value": _number(q["value"]), "formattedValue": q["value"]} for q in items],
133
+ columns=["query", "value", "formattedValue"],
134
+ ) if items else None
135
+ return out
136
+
137
+ def trending_searches(self, pn="united_states"):
138
+ """DataFrame with one column (0) of trending search titles, like pytrends."""
139
+ df = self.realtime_trending_searches(pn)
140
+ return pd.DataFrame({0: df["title"]}) if not df.empty else pd.DataFrame({0: []})
141
+
142
+ def realtime_trending_searches(self, pn="US", cat="all", count=300):
143
+ code = pn.upper() if len(pn) == 2 else COUNTRY_CODES.get(pn.lower())
144
+ if not code:
145
+ raise ValueError(f"Unknown country '{pn}'. Use a two-letter code such as US or GB.")
146
+ rows = [r for r in self._run({"trendingNowGeos": [code]}) if r.get("type") == "trendingNow"]
147
+ return pd.DataFrame(rows[:count])
148
+
149
+ today_searches = trending_searches
150
+
151
+ def related_topics(self):
152
+ raise TrendsError("related_topics is not available yet; related_queries is.")
153
+
154
+ def suggestions(self, keyword):
155
+ raise TrendsError("suggestions is not available yet.")
@@ -0,0 +1,58 @@
1
+ """Offline check: fake actor rows in, pytrends-shaped DataFrames out."""
2
+ import pytrends_ng
3
+ from pytrends_ng import TrendReq, _number
4
+
5
+ ROWS = [
6
+ {"type": "keyword", "keyword": "claude",
7
+ "timeline": [{"date": "2026-07-10T00:00:00Z", "value": 23}, {"date": "2026-07-11T00:00:00Z", "value": 20.5}],
8
+ "interestByRegion": [{"geoCode": "US-CA", "geoName": "California", "value": 100},
9
+ {"geoCode": "US-TX", "geoName": "Texas", "value": 0}],
10
+ "relatedQueriesTop": [{"query": "claude ai", "value": "100"}],
11
+ "relatedQueriesRising": [{"query": "claude 5", "value": "+3,800%"}, {"query": "claude code", "value": "Breakout"}]},
12
+ {"type": "keyword", "keyword": "gemini",
13
+ "timeline": [{"date": "2026-07-10T00:00:00Z", "value": 40}, {"date": "2026-07-11T00:00:00Z", "value": 37}],
14
+ "interestByRegion": [{"geoCode": "US-CA", "geoName": "California", "value": 80}],
15
+ "relatedQueriesTop": [], "relatedQueriesRising": []},
16
+ {"type": "trendingNow", "geo": "US", "title": "chicago marathon", "approxTraffic": "2000+"},
17
+ ]
18
+
19
+
20
+ def make(calls):
21
+ t = TrendReq(apify_token="x")
22
+
23
+ def fake_run(run_input):
24
+ calls.append(run_input)
25
+ return ROWS
26
+ t._run = fake_run
27
+ return t
28
+
29
+
30
+ def test_pytrends_shapes():
31
+ calls = []
32
+ t = make(calls)
33
+ t.build_payload(["claude", "gemini"], timeframe="today 3-m", geo="US")
34
+
35
+ iot = t.interest_over_time()
36
+ assert list(iot.columns) == ["claude", "gemini", "isPartial"]
37
+ assert iot.index.name == "date" and iot["claude"].iloc[1] == 20.5
38
+
39
+ rq = t.related_queries()
40
+ assert rq["claude"]["rising"]["value"].tolist()[0] == 3800
41
+ assert rq["claude"]["rising"]["formattedValue"].tolist()[1] == "Breakout"
42
+ assert rq["gemini"]["top"] is None
43
+ assert len(calls) == 1 # one run serves every method for the same payload
44
+
45
+ ibr = t.interest_by_region(resolution="REGION", inc_low_vol=False, inc_geo_code=True)
46
+ assert calls[-1]["regionLevel"] == "REGION" and len(calls) == 2
47
+ assert list(ibr.index) == ["California"] and ibr.loc["California", "geoCode"] == "US-CA"
48
+
49
+ assert t.trending_searches(pn="united_states")[0].tolist() == ["chicago marathon"]
50
+ assert calls[-1] == {"trendingNowGeos": ["US"]}
51
+
52
+
53
+ def test_number():
54
+ assert _number("+1,650%") == 1650 and _number("Breakout") is None and _number("81") == 81
55
+
56
+
57
+ if __name__ == "__main__":
58
+ test_pytrends_shapes(); test_number(); print("ok", pytrends_ng.__version__)