Skip to content

API Reference

trendflow

Client = GoogleTrendsFetcher module-attribute

TRENDING_WINDOW_RISING = 8 module-attribute

TRENDING_WINDOW_TOP = 10 module-attribute

TrendingBackend = Literal['auto', 'rpc', 'rss'] module-attribute

__all__ = ['TRENDING_WINDOW_RISING', 'TRENDING_WINDOW_TOP', 'Client', 'ExportFormat', 'GoogleTrendsFetcher', 'InterestByRegionResult', 'InterestOverTimeResult', 'RelatedQuery', 'RelatedResult', 'Region', 'Resolution', 'SearchProperty', 'Timeframe', 'TopicSuggestion', 'TrendingArticle', 'TrendingBackend', 'TrendingItem', 'TrendingResult', 'TrendPoint', 'TrendsFetcher', 'UnknownRpcError'] module-attribute

ExportFormat

Bases: StrEnum

Supported export targets for tabular trend data.

Source code in src/trendflow/enums.py
class ExportFormat(StrEnum):
    """Supported export targets for tabular trend data."""

    CSV = "csv"
    JSON = "json"

GoogleTrendsFetcher

Fetches data via the in-tree :class:GoogleTrendsHttpSession.

Source code in src/trendflow/_fetcher.py
class GoogleTrendsFetcher:
    """Fetches data via the in-tree :class:`GoogleTrendsHttpSession`."""

    def __init__(
        self,
        language: str = "en",
        timeout: int = 10,
        proxies: Sequence[str] | None = None,
        max_proxy_attempts: int | None = None,
        on_proxy_rotate: Callable[[int, Exception], None] | None = None,
        rpc_ids: Mapping[str, str] | None = None,
    ) -> None:
        """
        ``proxies`` is a list of proxy URLs to rotate through, from any mix of providers.

        One proxy is pinned per query and the pool advances only when a query fails, because
        Google binds its cookie and widget token to the exit IP. ``max_proxy_attempts``
        defaults to the pool size, capped at 5; ``on_proxy_rotate(attempt, error)`` is called
        each time the pool advances.

        ``rpc_ids`` repoints a `batchexecute` identifier that Google has renamed --
        ``{"trending": "...", "geo_list": "...", "suggestions": "..."}``, any subset. This is
        the escape hatch :class:`UnknownRpcError` refers to: a rename can be worked around in
        the caller, with no release and no fork.
        """
        to = (timeout, max(timeout * 2, timeout + 5))
        self._pool = ProxyPool(proxies) if proxies else None
        default_attempts = min(self._pool.size, 5) if self._pool else 1
        self._max_proxy_attempts = max_proxy_attempts if max_proxy_attempts is not None else default_attempts
        self._on_proxy_rotate = on_proxy_rotate
        self._req = GoogleTrendsHttpSession(
            hl=_hl_from_language(language),
            tz=360,
            timeout=to,
            proxies=[self._pool.current()] if self._pool else "",
            rpc_ids=rpc_ids,
        )
        self._rpc_trending: TrendingProvider = RpcTrendingProvider(self._req.rpc_client)
        self._rss_trending: TrendingProvider = RssTrendingProvider(self._req.rss_client)

    @property
    def current_proxy(self) -> str | None:
        """The proxy currently pinned for queries, if a pool is configured."""
        return self._pool.current() if self._pool else None

    def _with_rotation(self, operation: Callable[[], T]) -> T:
        """
        Run a query, moving to the next proxy and re-seeding the cookie jar if it fails in a
        way a different exit IP could fix.
        """
        if self._pool is None:
            return operation()

        attempts = max(1, min(self._pool.size, self._max_proxy_attempts))
        for attempt in range(1, attempts + 1):
            try:
                return operation()
            except Exception as error:
                if not _should_rotate(error) or attempt == attempts:
                    raise
                self._pool.advance()
                # The cookie and any cached widget token belong to the previous exit IP.
                self._req.set_proxy(self._pool.current())
                if self._on_proxy_rotate is not None:
                    self._on_proxy_rotate(attempt, error)
        raise AssertionError("unreachable")  # pragma: no cover

    def interest_over_time(
        self,
        keywords: list[str],
        timeframe: Timeframe | str,
        region: Region | str,
        *,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestOverTimeResult:
        """
        Relative interest for up to five terms over ``timeframe``.

        ``timeframe`` takes a :class:`Timeframe` or a custom ``"YYYY-MM-DD YYYY-MM-DD"`` range,
        and ``region`` any Google geo code -- a country, a sub-region like ``"US-CA"``, or a
        metro code. ``category`` restricts to one subject area, which disambiguates without a
        topic id: "jaguar" under Autos is the car. ``search_property`` chooses the surface;
        results from different properties are separate indexes and not comparable.
        """

        def run() -> InterestOverTimeResult:
            self._req.build_payload(
                keywords,
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            default = self._req.interest_over_time()
            return _parsers.interest_over_time_to_result(default, keywords, self._req.geo)

        return self._with_rotation(run)

    def interest_by_region(
        self,
        keyword: str,
        resolution: Resolution,
        region: Region | str = Region.US,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestByRegionResult:
        """
        Where ``keyword`` is searched, broken down at ``resolution``.

        ``timeframe`` defaults to the past year; narrow it to ask where something was searched
        during a specific window rather than across the whole year.
        """

        def run() -> InterestByRegionResult:
            self._req.build_payload(
                [keyword],
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            default = self._req.interest_by_region(
                resolution=resolution.value,
                inc_low_vol=True,
                inc_geo_code=False,
            )
            if not default.get("geoMapData"):
                return InterestByRegionResult(keyword=keyword, resolution=resolution, rows=[])
            return _parsers.interest_by_region_to_result(default, keyword, [keyword], resolution)

        return self._with_rotation(run)

    def trending_now(
        self,
        region: Region | str = Region.WORLDWIDE,
        window: int = TRENDING_WINDOW_RISING,
        backend: TrendingBackend = "auto",
    ) -> TrendingResult:
        """
        Trending searches for ``region``.

        Accepts any country code, not a fixed list, and worldwide works too. ``window``
        selects fastest-growing (:data:`TRENDING_WINDOW_RISING`) versus highest-volume
        (:data:`TRENDING_WINDOW_TOP`) results.

        ``backend`` selects the source:

        * ``"rpc"`` -- 50 items with growth percentages and volume.
        * ``"rss"`` -- 10 items with the **news articles** behind each trend, which the RPC
          does not carry, but no growth figures. ``window`` does not apply: Google ignores
          it on the feed. There is no worldwide feed, so a country code is required.
        * ``"auto"`` (default) -- the RPC, falling back to RSS if it fails. RPC first
          because it returns five times the items with real growth numbers; defaulting to
          RSS would quietly degrade results.

        :attr:`TrendingResult.source` reports which one answered.
        """
        geo = _trending_geo(region)

        def run(provider: TrendingProvider) -> TrendingResult:
            return TrendingResult(results=provider.fetch(geo, window), source=provider.source)

        if backend == "rpc":
            return self._with_rotation(lambda: run(self._rpc_trending))
        if backend == "rss":
            return self._with_rotation(lambda: run(self._rss_trending))

        def auto() -> TrendingResult:
            try:
                return run(self._rpc_trending)
            except Exception:  # noqa: BLE001 - the feed is a separate source; any RPC failure falls back
                return run(self._rss_trending)

        return self._with_rotation(auto)

    def related_queries(
        self,
        keyword: str,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        region: Region | str = Region.WORLDWIDE,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> RelatedResult:
        """
        Top and rising searches related to ``keyword``.

        ``region`` defaults to worldwide, which is what this returned unconditionally before it
        was a parameter -- pass a country to ask what is searched alongside the term *there*.
        """

        def run() -> RelatedResult:
            self._req.build_payload(
                [keyword],
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            raw = self._req.related_queries()
            return _parsers.related_queries_to_result(raw, keyword)

        return self._with_rotation(run)

    def suggestions(self, query: str) -> list[TopicSuggestion]:
        """
        Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

        Pass a returned ``mid`` as a keyword to any query method to measure the **topic**
        rather than the literal phrase; a topic aggregates every spelling and translation of
        the same concept, so it usually scores far higher than the raw string.

        Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints
        reject.
        """
        return self._with_rotation(
            lambda: _parsers.suggestion_rows_to_topics(self._req.suggestions(query)),
        )

    def geo_list(self) -> Any:
        """Every region Google accepts: ``[code, name, slug]`` per country, with subregions."""
        return self._with_rotation(self._req.geo_list)

current_proxy property

The proxy currently pinned for queries, if a pool is configured.

__init__(language='en', timeout=10, proxies=None, max_proxy_attempts=None, on_proxy_rotate=None, rpc_ids=None)

proxies is a list of proxy URLs to rotate through, from any mix of providers.

One proxy is pinned per query and the pool advances only when a query fails, because Google binds its cookie and widget token to the exit IP. max_proxy_attempts defaults to the pool size, capped at 5; on_proxy_rotate(attempt, error) is called each time the pool advances.

rpc_ids repoints a batchexecute identifier that Google has renamed -- {"trending": "...", "geo_list": "...", "suggestions": "..."}, any subset. This is the escape hatch :class:UnknownRpcError refers to: a rename can be worked around in the caller, with no release and no fork.

Source code in src/trendflow/_fetcher.py
def __init__(
    self,
    language: str = "en",
    timeout: int = 10,
    proxies: Sequence[str] | None = None,
    max_proxy_attempts: int | None = None,
    on_proxy_rotate: Callable[[int, Exception], None] | None = None,
    rpc_ids: Mapping[str, str] | None = None,
) -> None:
    """
    ``proxies`` is a list of proxy URLs to rotate through, from any mix of providers.

    One proxy is pinned per query and the pool advances only when a query fails, because
    Google binds its cookie and widget token to the exit IP. ``max_proxy_attempts``
    defaults to the pool size, capped at 5; ``on_proxy_rotate(attempt, error)`` is called
    each time the pool advances.

    ``rpc_ids`` repoints a `batchexecute` identifier that Google has renamed --
    ``{"trending": "...", "geo_list": "...", "suggestions": "..."}``, any subset. This is
    the escape hatch :class:`UnknownRpcError` refers to: a rename can be worked around in
    the caller, with no release and no fork.
    """
    to = (timeout, max(timeout * 2, timeout + 5))
    self._pool = ProxyPool(proxies) if proxies else None
    default_attempts = min(self._pool.size, 5) if self._pool else 1
    self._max_proxy_attempts = max_proxy_attempts if max_proxy_attempts is not None else default_attempts
    self._on_proxy_rotate = on_proxy_rotate
    self._req = GoogleTrendsHttpSession(
        hl=_hl_from_language(language),
        tz=360,
        timeout=to,
        proxies=[self._pool.current()] if self._pool else "",
        rpc_ids=rpc_ids,
    )
    self._rpc_trending: TrendingProvider = RpcTrendingProvider(self._req.rpc_client)
    self._rss_trending: TrendingProvider = RssTrendingProvider(self._req.rss_client)

geo_list()

Every region Google accepts: [code, name, slug] per country, with subregions.

Source code in src/trendflow/_fetcher.py
def geo_list(self) -> Any:
    """Every region Google accepts: ``[code, name, slug]`` per country, with subregions."""
    return self._with_rotation(self._req.geo_list)

interest_by_region(keyword, resolution, region=Region.US, *, timeframe=Timeframe.PAST_YEAR, category=0, search_property=SearchProperty.WEB)

Where keyword is searched, broken down at resolution.

timeframe defaults to the past year; narrow it to ask where something was searched during a specific window rather than across the whole year.

Source code in src/trendflow/_fetcher.py
def interest_by_region(
    self,
    keyword: str,
    resolution: Resolution,
    region: Region | str = Region.US,
    *,
    timeframe: Timeframe | str = Timeframe.PAST_YEAR,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> InterestByRegionResult:
    """
    Where ``keyword`` is searched, broken down at ``resolution``.

    ``timeframe`` defaults to the past year; narrow it to ask where something was searched
    during a specific window rather than across the whole year.
    """

    def run() -> InterestByRegionResult:
        self._req.build_payload(
            [keyword],
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        default = self._req.interest_by_region(
            resolution=resolution.value,
            inc_low_vol=True,
            inc_geo_code=False,
        )
        if not default.get("geoMapData"):
            return InterestByRegionResult(keyword=keyword, resolution=resolution, rows=[])
        return _parsers.interest_by_region_to_result(default, keyword, [keyword], resolution)

    return self._with_rotation(run)

interest_over_time(keywords, timeframe, region, *, category=0, search_property=SearchProperty.WEB)

Relative interest for up to five terms over timeframe.

timeframe takes a :class:Timeframe or a custom "YYYY-MM-DD YYYY-MM-DD" range, and region any Google geo code -- a country, a sub-region like "US-CA", or a metro code. category restricts to one subject area, which disambiguates without a topic id: "jaguar" under Autos is the car. search_property chooses the surface; results from different properties are separate indexes and not comparable.

Source code in src/trendflow/_fetcher.py
def interest_over_time(
    self,
    keywords: list[str],
    timeframe: Timeframe | str,
    region: Region | str,
    *,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> InterestOverTimeResult:
    """
    Relative interest for up to five terms over ``timeframe``.

    ``timeframe`` takes a :class:`Timeframe` or a custom ``"YYYY-MM-DD YYYY-MM-DD"`` range,
    and ``region`` any Google geo code -- a country, a sub-region like ``"US-CA"``, or a
    metro code. ``category`` restricts to one subject area, which disambiguates without a
    topic id: "jaguar" under Autos is the car. ``search_property`` chooses the surface;
    results from different properties are separate indexes and not comparable.
    """

    def run() -> InterestOverTimeResult:
        self._req.build_payload(
            keywords,
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        default = self._req.interest_over_time()
        return _parsers.interest_over_time_to_result(default, keywords, self._req.geo)

    return self._with_rotation(run)

related_queries(keyword, *, timeframe=Timeframe.PAST_YEAR, region=Region.WORLDWIDE, category=0, search_property=SearchProperty.WEB)

Top and rising searches related to keyword.

region defaults to worldwide, which is what this returned unconditionally before it was a parameter -- pass a country to ask what is searched alongside the term there.

Source code in src/trendflow/_fetcher.py
def related_queries(
    self,
    keyword: str,
    *,
    timeframe: Timeframe | str = Timeframe.PAST_YEAR,
    region: Region | str = Region.WORLDWIDE,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> RelatedResult:
    """
    Top and rising searches related to ``keyword``.

    ``region`` defaults to worldwide, which is what this returned unconditionally before it
    was a parameter -- pass a country to ask what is searched alongside the term *there*.
    """

    def run() -> RelatedResult:
        self._req.build_payload(
            [keyword],
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        raw = self._req.related_queries()
        return _parsers.related_queries_to_result(raw, keyword)

    return self._with_rotation(run)

suggestions(query)

Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

Pass a returned mid as a keyword to any query method to measure the topic rather than the literal phrase; a topic aggregates every spelling and translation of the same concept, so it usually scores far higher than the raw string.

Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints reject.

Source code in src/trendflow/_fetcher.py
def suggestions(self, query: str) -> list[TopicSuggestion]:
    """
    Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

    Pass a returned ``mid`` as a keyword to any query method to measure the **topic**
    rather than the literal phrase; a topic aggregates every spelling and translation of
    the same concept, so it usually scores far higher than the raw string.

    Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints
    reject.
    """
    return self._with_rotation(
        lambda: _parsers.suggestion_rows_to_topics(self._req.suggestions(query)),
    )

trending_now(region=Region.WORLDWIDE, window=TRENDING_WINDOW_RISING, backend='auto')

Trending searches for region.

Accepts any country code, not a fixed list, and worldwide works too. window selects fastest-growing (:data:TRENDING_WINDOW_RISING) versus highest-volume (:data:TRENDING_WINDOW_TOP) results.

backend selects the source:

  • "rpc" -- 50 items with growth percentages and volume.
  • "rss" -- 10 items with the news articles behind each trend, which the RPC does not carry, but no growth figures. window does not apply: Google ignores it on the feed. There is no worldwide feed, so a country code is required.
  • "auto" (default) -- the RPC, falling back to RSS if it fails. RPC first because it returns five times the items with real growth numbers; defaulting to RSS would quietly degrade results.

:attr:TrendingResult.source reports which one answered.

Source code in src/trendflow/_fetcher.py
def trending_now(
    self,
    region: Region | str = Region.WORLDWIDE,
    window: int = TRENDING_WINDOW_RISING,
    backend: TrendingBackend = "auto",
) -> TrendingResult:
    """
    Trending searches for ``region``.

    Accepts any country code, not a fixed list, and worldwide works too. ``window``
    selects fastest-growing (:data:`TRENDING_WINDOW_RISING`) versus highest-volume
    (:data:`TRENDING_WINDOW_TOP`) results.

    ``backend`` selects the source:

    * ``"rpc"`` -- 50 items with growth percentages and volume.
    * ``"rss"`` -- 10 items with the **news articles** behind each trend, which the RPC
      does not carry, but no growth figures. ``window`` does not apply: Google ignores
      it on the feed. There is no worldwide feed, so a country code is required.
    * ``"auto"`` (default) -- the RPC, falling back to RSS if it fails. RPC first
      because it returns five times the items with real growth numbers; defaulting to
      RSS would quietly degrade results.

    :attr:`TrendingResult.source` reports which one answered.
    """
    geo = _trending_geo(region)

    def run(provider: TrendingProvider) -> TrendingResult:
        return TrendingResult(results=provider.fetch(geo, window), source=provider.source)

    if backend == "rpc":
        return self._with_rotation(lambda: run(self._rpc_trending))
    if backend == "rss":
        return self._with_rotation(lambda: run(self._rss_trending))

    def auto() -> TrendingResult:
        try:
            return run(self._rpc_trending)
        except Exception:  # noqa: BLE001 - the feed is a separate source; any RPC failure falls back
            return run(self._rss_trending)

    return self._with_rotation(auto)

InterestByRegionResult dataclass

Regional popularity for a single keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestByRegionResult:
    """Regional popularity for a single keyword."""

    keyword: str
    resolution: Resolution
    rows: list[RegionalInterestRow]

InterestOverTimeResult dataclass

Interest over time for one or more keywords.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestOverTimeResult:
    """Interest over time for one or more keywords."""

    keywords: list[str]
    granularity: str
    points: list[TrendPoint]

    def to_dataframe(self) -> pd.DataFrame:
        """Build a pandas DataFrame with a `date` column and one column per keyword."""
        if not self.points:
            return pd.DataFrame(columns=["date", *self.keywords])
        rows: list[dict[str, Any]] = []
        for p in self.points:
            rows.append({"date": p.date, **p.scores})
        return pd.DataFrame(rows)

    def export(self, fmt: ExportFormat, path: str | Path) -> None:
        """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
        from trendflow._exporters import export_interest_over_time

        export_interest_over_time(self, fmt, Path(path))

export(fmt, path)

Write results to CSV or JSON (UTF-8) via :mod:trendflow._exporters.

Source code in src/trendflow/models.py
def export(self, fmt: ExportFormat, path: str | Path) -> None:
    """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
    from trendflow._exporters import export_interest_over_time

    export_interest_over_time(self, fmt, Path(path))

to_dataframe()

Build a pandas DataFrame with a date column and one column per keyword.

Source code in src/trendflow/models.py
def to_dataframe(self) -> pd.DataFrame:
    """Build a pandas DataFrame with a `date` column and one column per keyword."""
    if not self.points:
        return pd.DataFrame(columns=["date", *self.keywords])
    rows: list[dict[str, Any]] = []
    for p in self.points:
        rows.append({"date": p.date, **p.scores})
    return pd.DataFrame(rows)

Region

Bases: StrEnum

ISO-style geo codes for Google Trends (hl / geo). Empty string is worldwide.

Source code in src/trendflow/enums.py
class Region(StrEnum):
    """ISO-style geo codes for Google Trends (`hl` / `geo`). Empty string is worldwide."""

    WORLDWIDE = ""
    US = "US"
    GB = "GB"
    DE = "DE"
    FR = "FR"
    IT = "IT"
    ES = "ES"
    CA = "CA"
    AU = "AU"
    JP = "JP"
    IN = "IN"
    BR = "BR"
    MX = "MX"
    NL = "NL"
    SE = "SE"
    PL = "PL"
    TR = "TR"

RelatedQuery dataclass

A top or rising related query.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedQuery:
    """A top or rising related query."""

    term: str
    value: int | None = None
    breakout: str | None = None

RelatedResult dataclass

Related queries for a seed keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedResult:
    """Related queries for a seed keyword."""

    top: list[RelatedQuery]
    rising: list[RelatedQuery]

Resolution

Bases: StrEnum

Granularity for regional interest breakdowns.

Source code in src/trendflow/enums.py
class Resolution(StrEnum):
    """Granularity for regional interest breakdowns."""

    COUNTRY = "COUNTRY"
    REGION = "REGION"
    CITY = "CITY"

SearchProperty

Bases: StrEnum

Which Google surface to measure.

These are separate indexes, not filters over one dataset, so the same term can look very different across them -- a term may be quiet on web search and busy on YouTube. Values are only comparable within a single property.

Source code in src/trendflow/enums.py
class SearchProperty(StrEnum):
    """
    Which Google surface to measure.

    These are separate indexes, not filters over one dataset, so the same term can look very
    different across them -- a term may be quiet on web search and busy on YouTube. Values are
    only comparable within a single property.
    """

    WEB = ""
    IMAGES = "images"
    NEWS = "news"
    YOUTUBE = "youtube"
    SHOPPING = "froogle"

Timeframe

Bases: StrEnum

Time ranges accepted by Google Trends.

Named values for the presets. A custom range is a plain string of two ISO dates, "2023-01-01 2023-06-30", and every query method accepts one in place of a member.

The range chosen also decides the granularity Google returns: the hourly ranges come back in minutes, the daily ones hourly, and ALL_TIME monthly. Ask for five years and you cannot see a spike that lasted an afternoon.

Source code in src/trendflow/enums.py
class Timeframe(StrEnum):
    """
    Time ranges accepted by Google Trends.

    Named values for the presets. A custom range is a plain string of two ISO dates,
    ``"2023-01-01 2023-06-30"``, and every query method accepts one in place of a member.

    The range chosen also decides the granularity Google returns: the hourly ranges come back
    in minutes, the daily ones hourly, and ``ALL_TIME`` monthly. Ask for five years and you
    cannot see a spike that lasted an afternoon.
    """

    PAST_HOUR = "now 1-H"
    PAST_4_HOURS = "now 4-H"
    PAST_DAY = "now 1-d"
    PAST_WEEK = "now 7-d"
    PAST_MONTH = "today 1-m"
    PAST_3_MONTHS = "today 3-m"
    PAST_YEAR = "today 12-m"
    PAST_5_YEARS = "today 5-y"
    ALL_TIME = "all"

TopicSuggestion dataclass

An entity Google recognises, as returned by search suggestions.

mid is the identifier to pass as a keyword to query the topic rather than the literal phrase -- a topic aggregates every spelling and translation of the same concept.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TopicSuggestion:
    """
    An entity Google recognises, as returned by search suggestions.

    ``mid`` is the identifier to pass as a keyword to query the **topic** rather than the
    literal phrase -- a topic aggregates every spelling and translation of the same concept.
    """

    #: Freebase-style entity id, e.g. ``"/m/0mkz"``. Pass this as a keyword.
    mid: str
    #: Display name, e.g. ``"Artificial intelligence"``.
    title: str
    #: Disambiguating descriptor, e.g. ``"Professional field"``. ``None`` when Google omits it.
    type: str | None = None

TrendPoint dataclass

One timestamp in an interest-over-time series.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendPoint:
    """One timestamp in an interest-over-time series."""

    date: datetime
    scores: dict[str, int]

TrendingArticle dataclass

A news article behind a trending search. Only the RSS backend reports these.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingArticle:
    """A news article behind a trending search. Only the RSS backend reports these."""

    title: str
    url: str
    source: str
    picture: str | None = None

TrendingItem dataclass

A single trending search entry.

Both backends fill title and traffic; the rest depends on which one answered, since Google exposes different fields on each. See :attr:TrendingResult.source.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingItem:
    """
    A single trending search entry.

    Both backends fill ``title`` and ``traffic``; the rest depends on which one answered,
    since Google exposes different fields on each. See :attr:`TrendingResult.source`.
    """

    title: str
    #: Human-readable traffic: ``"+3,950%"`` from the RPC, ``"2000+"`` from RSS.
    traffic: str
    #: News articles behind the trend. RSS backend only; empty from the RPC.
    articles: list[TrendingArticle] = field(default_factory=list)
    #: Percentage increase over the window, e.g. ``3950``. RPC backend only.
    growth: int | None = None
    #: Relative search volume, on Google's own 0-100 style scale. RPC backend only.
    volume: int | None = None
    #: When Google started reporting the trend. RSS backend only.
    started_at: datetime | None = None

TrendingResult dataclass

Current trending searches for a region.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingResult:
    """Current trending searches for a region."""

    results: list[TrendingItem]
    #: Which backend answered -- useful when ``backend="auto"`` picked for you.
    source: str = "rpc"

TrendsFetcher

Bases: Protocol

Strategy for retrieving Trends data (swap in tests or alternate backends).

Source code in src/trendflow/_fetcher.py
@runtime_checkable
class TrendsFetcher(Protocol):
    """Strategy for retrieving Trends data (swap in tests or alternate backends)."""

    def interest_over_time(
        self,
        keywords: list[str],
        timeframe: Timeframe | str,
        region: Region | str,
        *,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestOverTimeResult: ...

    def interest_by_region(
        self,
        keyword: str,
        resolution: Resolution,
        region: Region | str = Region.US,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestByRegionResult: ...

    def trending_now(
        self,
        region: Region | str = Region.WORLDWIDE,
        window: int = ...,
        backend: TrendingBackend = ...,
    ) -> TrendingResult: ...

    def related_queries(
        self,
        keyword: str,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        region: Region | str = Region.WORLDWIDE,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> RelatedResult: ...

    def suggestions(self, query: str) -> list[TopicSuggestion]: ...

UnknownRpcError

Bases: Exception

Google returned no frame for the RPC that was called.

That is the signature of an identifier renamed on Google's side.

Source code in src/trendflow/_trends_http/batchexecute.py
class UnknownRpcError(Exception):
    """
    Google returned no frame for the RPC that was called.

    That is the signature of an identifier renamed on Google's side.
    """

    def __init__(self, rpc_id: str) -> None:
        super().__init__(
            f"Google returned no data for RPC {rpc_id!r}. This usually means the RPC "
            f"identifier changed on Google's side. Override it with the `rpc_ids` argument, "
            f"and please open an issue at https://github.com/dariomory/trendflow/issues",
        )
        self.rpc_id = rpc_id

trendflow._fetcher

Gprop = Literal['', 'images', 'news', 'youtube', 'froogle'] module-attribute

HTTP_FORBIDDEN = 403 module-attribute

T = TypeVar('T') module-attribute

TRENDING_WINDOW_RISING = 8 module-attribute

TRENDING_WINDOW_TOP = 10 module-attribute

TrendingBackend = Literal['auto', 'rpc', 'rss'] module-attribute

_SEARCH_PROPERTIES = frozenset((str(p)) for p in SearchProperty) module-attribute

GoogleTrendsFetcher

Fetches data via the in-tree :class:GoogleTrendsHttpSession.

Source code in src/trendflow/_fetcher.py
class GoogleTrendsFetcher:
    """Fetches data via the in-tree :class:`GoogleTrendsHttpSession`."""

    def __init__(
        self,
        language: str = "en",
        timeout: int = 10,
        proxies: Sequence[str] | None = None,
        max_proxy_attempts: int | None = None,
        on_proxy_rotate: Callable[[int, Exception], None] | None = None,
        rpc_ids: Mapping[str, str] | None = None,
    ) -> None:
        """
        ``proxies`` is a list of proxy URLs to rotate through, from any mix of providers.

        One proxy is pinned per query and the pool advances only when a query fails, because
        Google binds its cookie and widget token to the exit IP. ``max_proxy_attempts``
        defaults to the pool size, capped at 5; ``on_proxy_rotate(attempt, error)`` is called
        each time the pool advances.

        ``rpc_ids`` repoints a `batchexecute` identifier that Google has renamed --
        ``{"trending": "...", "geo_list": "...", "suggestions": "..."}``, any subset. This is
        the escape hatch :class:`UnknownRpcError` refers to: a rename can be worked around in
        the caller, with no release and no fork.
        """
        to = (timeout, max(timeout * 2, timeout + 5))
        self._pool = ProxyPool(proxies) if proxies else None
        default_attempts = min(self._pool.size, 5) if self._pool else 1
        self._max_proxy_attempts = max_proxy_attempts if max_proxy_attempts is not None else default_attempts
        self._on_proxy_rotate = on_proxy_rotate
        self._req = GoogleTrendsHttpSession(
            hl=_hl_from_language(language),
            tz=360,
            timeout=to,
            proxies=[self._pool.current()] if self._pool else "",
            rpc_ids=rpc_ids,
        )
        self._rpc_trending: TrendingProvider = RpcTrendingProvider(self._req.rpc_client)
        self._rss_trending: TrendingProvider = RssTrendingProvider(self._req.rss_client)

    @property
    def current_proxy(self) -> str | None:
        """The proxy currently pinned for queries, if a pool is configured."""
        return self._pool.current() if self._pool else None

    def _with_rotation(self, operation: Callable[[], T]) -> T:
        """
        Run a query, moving to the next proxy and re-seeding the cookie jar if it fails in a
        way a different exit IP could fix.
        """
        if self._pool is None:
            return operation()

        attempts = max(1, min(self._pool.size, self._max_proxy_attempts))
        for attempt in range(1, attempts + 1):
            try:
                return operation()
            except Exception as error:
                if not _should_rotate(error) or attempt == attempts:
                    raise
                self._pool.advance()
                # The cookie and any cached widget token belong to the previous exit IP.
                self._req.set_proxy(self._pool.current())
                if self._on_proxy_rotate is not None:
                    self._on_proxy_rotate(attempt, error)
        raise AssertionError("unreachable")  # pragma: no cover

    def interest_over_time(
        self,
        keywords: list[str],
        timeframe: Timeframe | str,
        region: Region | str,
        *,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestOverTimeResult:
        """
        Relative interest for up to five terms over ``timeframe``.

        ``timeframe`` takes a :class:`Timeframe` or a custom ``"YYYY-MM-DD YYYY-MM-DD"`` range,
        and ``region`` any Google geo code -- a country, a sub-region like ``"US-CA"``, or a
        metro code. ``category`` restricts to one subject area, which disambiguates without a
        topic id: "jaguar" under Autos is the car. ``search_property`` chooses the surface;
        results from different properties are separate indexes and not comparable.
        """

        def run() -> InterestOverTimeResult:
            self._req.build_payload(
                keywords,
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            default = self._req.interest_over_time()
            return _parsers.interest_over_time_to_result(default, keywords, self._req.geo)

        return self._with_rotation(run)

    def interest_by_region(
        self,
        keyword: str,
        resolution: Resolution,
        region: Region | str = Region.US,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestByRegionResult:
        """
        Where ``keyword`` is searched, broken down at ``resolution``.

        ``timeframe`` defaults to the past year; narrow it to ask where something was searched
        during a specific window rather than across the whole year.
        """

        def run() -> InterestByRegionResult:
            self._req.build_payload(
                [keyword],
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            default = self._req.interest_by_region(
                resolution=resolution.value,
                inc_low_vol=True,
                inc_geo_code=False,
            )
            if not default.get("geoMapData"):
                return InterestByRegionResult(keyword=keyword, resolution=resolution, rows=[])
            return _parsers.interest_by_region_to_result(default, keyword, [keyword], resolution)

        return self._with_rotation(run)

    def trending_now(
        self,
        region: Region | str = Region.WORLDWIDE,
        window: int = TRENDING_WINDOW_RISING,
        backend: TrendingBackend = "auto",
    ) -> TrendingResult:
        """
        Trending searches for ``region``.

        Accepts any country code, not a fixed list, and worldwide works too. ``window``
        selects fastest-growing (:data:`TRENDING_WINDOW_RISING`) versus highest-volume
        (:data:`TRENDING_WINDOW_TOP`) results.

        ``backend`` selects the source:

        * ``"rpc"`` -- 50 items with growth percentages and volume.
        * ``"rss"`` -- 10 items with the **news articles** behind each trend, which the RPC
          does not carry, but no growth figures. ``window`` does not apply: Google ignores
          it on the feed. There is no worldwide feed, so a country code is required.
        * ``"auto"`` (default) -- the RPC, falling back to RSS if it fails. RPC first
          because it returns five times the items with real growth numbers; defaulting to
          RSS would quietly degrade results.

        :attr:`TrendingResult.source` reports which one answered.
        """
        geo = _trending_geo(region)

        def run(provider: TrendingProvider) -> TrendingResult:
            return TrendingResult(results=provider.fetch(geo, window), source=provider.source)

        if backend == "rpc":
            return self._with_rotation(lambda: run(self._rpc_trending))
        if backend == "rss":
            return self._with_rotation(lambda: run(self._rss_trending))

        def auto() -> TrendingResult:
            try:
                return run(self._rpc_trending)
            except Exception:  # noqa: BLE001 - the feed is a separate source; any RPC failure falls back
                return run(self._rss_trending)

        return self._with_rotation(auto)

    def related_queries(
        self,
        keyword: str,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        region: Region | str = Region.WORLDWIDE,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> RelatedResult:
        """
        Top and rising searches related to ``keyword``.

        ``region`` defaults to worldwide, which is what this returned unconditionally before it
        was a parameter -- pass a country to ask what is searched alongside the term *there*.
        """

        def run() -> RelatedResult:
            self._req.build_payload(
                [keyword],
                cat=category,
                timeframe=_param(timeframe),
                geo=_param(region),
                gprop=_gprop(search_property),
            )
            raw = self._req.related_queries()
            return _parsers.related_queries_to_result(raw, keyword)

        return self._with_rotation(run)

    def suggestions(self, query: str) -> list[TopicSuggestion]:
        """
        Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

        Pass a returned ``mid`` as a keyword to any query method to measure the **topic**
        rather than the literal phrase; a topic aggregates every spelling and translation of
        the same concept, so it usually scores far higher than the raw string.

        Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints
        reject.
        """
        return self._with_rotation(
            lambda: _parsers.suggestion_rows_to_topics(self._req.suggestions(query)),
        )

    def geo_list(self) -> Any:
        """Every region Google accepts: ``[code, name, slug]`` per country, with subregions."""
        return self._with_rotation(self._req.geo_list)

current_proxy property

The proxy currently pinned for queries, if a pool is configured.

__init__(language='en', timeout=10, proxies=None, max_proxy_attempts=None, on_proxy_rotate=None, rpc_ids=None)

proxies is a list of proxy URLs to rotate through, from any mix of providers.

One proxy is pinned per query and the pool advances only when a query fails, because Google binds its cookie and widget token to the exit IP. max_proxy_attempts defaults to the pool size, capped at 5; on_proxy_rotate(attempt, error) is called each time the pool advances.

rpc_ids repoints a batchexecute identifier that Google has renamed -- {"trending": "...", "geo_list": "...", "suggestions": "..."}, any subset. This is the escape hatch :class:UnknownRpcError refers to: a rename can be worked around in the caller, with no release and no fork.

Source code in src/trendflow/_fetcher.py
def __init__(
    self,
    language: str = "en",
    timeout: int = 10,
    proxies: Sequence[str] | None = None,
    max_proxy_attempts: int | None = None,
    on_proxy_rotate: Callable[[int, Exception], None] | None = None,
    rpc_ids: Mapping[str, str] | None = None,
) -> None:
    """
    ``proxies`` is a list of proxy URLs to rotate through, from any mix of providers.

    One proxy is pinned per query and the pool advances only when a query fails, because
    Google binds its cookie and widget token to the exit IP. ``max_proxy_attempts``
    defaults to the pool size, capped at 5; ``on_proxy_rotate(attempt, error)`` is called
    each time the pool advances.

    ``rpc_ids`` repoints a `batchexecute` identifier that Google has renamed --
    ``{"trending": "...", "geo_list": "...", "suggestions": "..."}``, any subset. This is
    the escape hatch :class:`UnknownRpcError` refers to: a rename can be worked around in
    the caller, with no release and no fork.
    """
    to = (timeout, max(timeout * 2, timeout + 5))
    self._pool = ProxyPool(proxies) if proxies else None
    default_attempts = min(self._pool.size, 5) if self._pool else 1
    self._max_proxy_attempts = max_proxy_attempts if max_proxy_attempts is not None else default_attempts
    self._on_proxy_rotate = on_proxy_rotate
    self._req = GoogleTrendsHttpSession(
        hl=_hl_from_language(language),
        tz=360,
        timeout=to,
        proxies=[self._pool.current()] if self._pool else "",
        rpc_ids=rpc_ids,
    )
    self._rpc_trending: TrendingProvider = RpcTrendingProvider(self._req.rpc_client)
    self._rss_trending: TrendingProvider = RssTrendingProvider(self._req.rss_client)

geo_list()

Every region Google accepts: [code, name, slug] per country, with subregions.

Source code in src/trendflow/_fetcher.py
def geo_list(self) -> Any:
    """Every region Google accepts: ``[code, name, slug]`` per country, with subregions."""
    return self._with_rotation(self._req.geo_list)

interest_by_region(keyword, resolution, region=Region.US, *, timeframe=Timeframe.PAST_YEAR, category=0, search_property=SearchProperty.WEB)

Where keyword is searched, broken down at resolution.

timeframe defaults to the past year; narrow it to ask where something was searched during a specific window rather than across the whole year.

Source code in src/trendflow/_fetcher.py
def interest_by_region(
    self,
    keyword: str,
    resolution: Resolution,
    region: Region | str = Region.US,
    *,
    timeframe: Timeframe | str = Timeframe.PAST_YEAR,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> InterestByRegionResult:
    """
    Where ``keyword`` is searched, broken down at ``resolution``.

    ``timeframe`` defaults to the past year; narrow it to ask where something was searched
    during a specific window rather than across the whole year.
    """

    def run() -> InterestByRegionResult:
        self._req.build_payload(
            [keyword],
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        default = self._req.interest_by_region(
            resolution=resolution.value,
            inc_low_vol=True,
            inc_geo_code=False,
        )
        if not default.get("geoMapData"):
            return InterestByRegionResult(keyword=keyword, resolution=resolution, rows=[])
        return _parsers.interest_by_region_to_result(default, keyword, [keyword], resolution)

    return self._with_rotation(run)

interest_over_time(keywords, timeframe, region, *, category=0, search_property=SearchProperty.WEB)

Relative interest for up to five terms over timeframe.

timeframe takes a :class:Timeframe or a custom "YYYY-MM-DD YYYY-MM-DD" range, and region any Google geo code -- a country, a sub-region like "US-CA", or a metro code. category restricts to one subject area, which disambiguates without a topic id: "jaguar" under Autos is the car. search_property chooses the surface; results from different properties are separate indexes and not comparable.

Source code in src/trendflow/_fetcher.py
def interest_over_time(
    self,
    keywords: list[str],
    timeframe: Timeframe | str,
    region: Region | str,
    *,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> InterestOverTimeResult:
    """
    Relative interest for up to five terms over ``timeframe``.

    ``timeframe`` takes a :class:`Timeframe` or a custom ``"YYYY-MM-DD YYYY-MM-DD"`` range,
    and ``region`` any Google geo code -- a country, a sub-region like ``"US-CA"``, or a
    metro code. ``category`` restricts to one subject area, which disambiguates without a
    topic id: "jaguar" under Autos is the car. ``search_property`` chooses the surface;
    results from different properties are separate indexes and not comparable.
    """

    def run() -> InterestOverTimeResult:
        self._req.build_payload(
            keywords,
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        default = self._req.interest_over_time()
        return _parsers.interest_over_time_to_result(default, keywords, self._req.geo)

    return self._with_rotation(run)

related_queries(keyword, *, timeframe=Timeframe.PAST_YEAR, region=Region.WORLDWIDE, category=0, search_property=SearchProperty.WEB)

Top and rising searches related to keyword.

region defaults to worldwide, which is what this returned unconditionally before it was a parameter -- pass a country to ask what is searched alongside the term there.

Source code in src/trendflow/_fetcher.py
def related_queries(
    self,
    keyword: str,
    *,
    timeframe: Timeframe | str = Timeframe.PAST_YEAR,
    region: Region | str = Region.WORLDWIDE,
    category: int = 0,
    search_property: SearchProperty | str = SearchProperty.WEB,
) -> RelatedResult:
    """
    Top and rising searches related to ``keyword``.

    ``region`` defaults to worldwide, which is what this returned unconditionally before it
    was a parameter -- pass a country to ask what is searched alongside the term *there*.
    """

    def run() -> RelatedResult:
        self._req.build_payload(
            [keyword],
            cat=category,
            timeframe=_param(timeframe),
            geo=_param(region),
            gprop=_gprop(search_property),
        )
        raw = self._req.related_queries()
        return _parsers.related_queries_to_result(raw, keyword)

    return self._with_rotation(run)

suggestions(query)

Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

Pass a returned mid as a keyword to any query method to measure the topic rather than the literal phrase; a topic aggregates every spelling and translation of the same concept, so it usually scores far higher than the raw string.

Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints reject.

Source code in src/trendflow/_fetcher.py
def suggestions(self, query: str) -> list[TopicSuggestion]:
    """
    Entity suggestions for a partial query -- the picker behind the UI's "Topic" results.

    Pass a returned ``mid`` as a keyword to any query method to measure the **topic**
    rather than the literal phrase; a topic aggregates every spelling and translation of
    the same concept, so it usually scores far higher than the raw string.

    Needs no cookie and no proxy: this RPC answers on IPs the widgetdata endpoints
    reject.
    """
    return self._with_rotation(
        lambda: _parsers.suggestion_rows_to_topics(self._req.suggestions(query)),
    )

trending_now(region=Region.WORLDWIDE, window=TRENDING_WINDOW_RISING, backend='auto')

Trending searches for region.

Accepts any country code, not a fixed list, and worldwide works too. window selects fastest-growing (:data:TRENDING_WINDOW_RISING) versus highest-volume (:data:TRENDING_WINDOW_TOP) results.

backend selects the source:

  • "rpc" -- 50 items with growth percentages and volume.
  • "rss" -- 10 items with the news articles behind each trend, which the RPC does not carry, but no growth figures. window does not apply: Google ignores it on the feed. There is no worldwide feed, so a country code is required.
  • "auto" (default) -- the RPC, falling back to RSS if it fails. RPC first because it returns five times the items with real growth numbers; defaulting to RSS would quietly degrade results.

:attr:TrendingResult.source reports which one answered.

Source code in src/trendflow/_fetcher.py
def trending_now(
    self,
    region: Region | str = Region.WORLDWIDE,
    window: int = TRENDING_WINDOW_RISING,
    backend: TrendingBackend = "auto",
) -> TrendingResult:
    """
    Trending searches for ``region``.

    Accepts any country code, not a fixed list, and worldwide works too. ``window``
    selects fastest-growing (:data:`TRENDING_WINDOW_RISING`) versus highest-volume
    (:data:`TRENDING_WINDOW_TOP`) results.

    ``backend`` selects the source:

    * ``"rpc"`` -- 50 items with growth percentages and volume.
    * ``"rss"`` -- 10 items with the **news articles** behind each trend, which the RPC
      does not carry, but no growth figures. ``window`` does not apply: Google ignores
      it on the feed. There is no worldwide feed, so a country code is required.
    * ``"auto"`` (default) -- the RPC, falling back to RSS if it fails. RPC first
      because it returns five times the items with real growth numbers; defaulting to
      RSS would quietly degrade results.

    :attr:`TrendingResult.source` reports which one answered.
    """
    geo = _trending_geo(region)

    def run(provider: TrendingProvider) -> TrendingResult:
        return TrendingResult(results=provider.fetch(geo, window), source=provider.source)

    if backend == "rpc":
        return self._with_rotation(lambda: run(self._rpc_trending))
    if backend == "rss":
        return self._with_rotation(lambda: run(self._rss_trending))

    def auto() -> TrendingResult:
        try:
            return run(self._rpc_trending)
        except Exception:  # noqa: BLE001 - the feed is a separate source; any RPC failure falls back
            return run(self._rss_trending)

    return self._with_rotation(auto)

GoogleTrendsHttpSession

Stateful client for Google Trends internal APIs (explore + widgetdata).

Composes :class:TrendsJsonTransport for HTTP; this class holds comparison state and returns raw JSON for callers to parse (e.g. :mod:trendflow._parsers).

Source code in src/trendflow/_trends_http/session.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
class GoogleTrendsHttpSession:
    """
    Stateful client for Google Trends internal APIs (explore + widgetdata).

    Composes :class:`TrendsJsonTransport` for HTTP; this class holds comparison
    state and returns raw JSON for callers to parse (e.g. :mod:`trendflow._parsers`).
    """

    def __init__(
        self,
        hl: str = "en-US",
        tz: int = 360,
        geo: str = "",
        timeout: httpx.Timeout | tuple[float, float] | float = (2, 5),
        proxies: str | Sequence[str] = "",
        retries: int = 0,
        backoff_factor: float = 0,
        requests_args: Mapping[str, Any] | None = None,
        rpc_ids: Mapping[str, str] | None = None,
    ) -> None:
        self.tz = tz
        self.hl = hl
        self.geo: str | list[str] = geo
        self.kw_list: list[str] = []
        self.timeout = timeout
        self.proxies = _normalize_proxies(proxies)
        self.retries = retries
        self.backoff_factor = backoff_factor
        self.requests_args: dict[str, Any] = dict(requests_args or {})
        self.results: Any = None

        headers: MutableMapping[str, str] = {
            "accept": "application/json, text/plain, */*",
            "accept-language": self.hl,
            "user-agent": DEFAULT_USER_AGENT,
            "origin": "https://trends.google.com",
            "referer": f"{ep.BASE_TRENDS_URL}/explore",
        }
        headers.update(self.requests_args.pop("headers", {}))

        self._http = TrendsJsonTransport(
            hl=self.hl,
            tz=self.tz,
            timeout=self.timeout,
            headers=headers,
            extra_client_args=self.requests_args,
            proxy_urls=self.proxies,
            retries=self.retries,
        )

        self._rpc = BatchExecuteClient(
            hl=self.hl,
            timeout=self.timeout,
            headers=dict(headers),
            rpc_ids=rpc_ids,
            proxy=self.proxies[0] if self.proxies else None,
        )

        self._rss = TrendingRssClient(
            timeout=self.timeout,
            headers=dict(headers),
            proxy=self.proxies[0] if self.proxies else None,
        )

        self.token_payload: dict[str, Any] = {}
        self.interest_over_time_widget: dict[str, Any] = {}
        self.interest_by_region_widget: dict[str, Any] = {}
        self.related_topics_widget_list: list[dict[str, Any]] = []
        self.related_queries_widget_list: list[dict[str, Any]] = []

    @property
    def proxy_index(self) -> int:
        return self._http._proxy_index

    def set_proxy(self, proxy_url: str) -> None:
        """Pin a proxy for every subsequent request and drop the old exit IP's cookie jar."""
        self.proxies = [proxy_url]
        self._http.set_proxy(proxy_url)
        self._rpc.proxy = proxy_url
        self._rss.proxy = proxy_url

    def reset_cookies(self) -> None:
        """Drop the cookie jar; the cached widget tokens are re-fetched by build_payload."""
        self._http.reset_cookies()

    @property
    def cookies(self) -> dict[str, str]:
        return self._http.cookies

    @cookies.setter
    def cookies(self, value: dict[str, str]) -> None:
        self._http.cookies = value

    def _get_data(self, url: str, method: Literal["get", "post"] = "get", trim_chars: int = 0, **kwargs: Any) -> Any:
        return self._http.request_json(url, method, trim_chars=trim_chars, **kwargs)

    def build_payload(
        self,
        kw_list: list[str],
        cat: int = 0,
        timeframe: str | list[str] = "today 5-y",
        geo: str = "",
        gprop: Gprop = "",
    ) -> None:
        allowed: tuple[str, ...] = ("", "images", "news", "youtube", "froogle")
        if gprop not in allowed:
            raise ValueError(
                "gprop must be empty (web), images, news, youtube, or froogle",
            )
        self.kw_list = kw_list
        self.geo = geo or self.geo
        self.token_payload = {
            "hl": self.hl,
            "tz": self.tz,
            "req": {"comparisonItem": [], "category": cat, "property": gprop},
        }

        if not isinstance(self.geo, list):
            self.geo = [self.geo]

        if isinstance(timeframe, list):
            for index, (kw, geo_item) in enumerate(product(self.kw_list, self.geo)):
                payload = {"keyword": kw, "time": timeframe[index], "geo": geo_item}
                self.token_payload["req"]["comparisonItem"].append(payload)
        else:
            for kw, geo_item in product(self.kw_list, self.geo):
                payload = {"keyword": kw, "time": timeframe, "geo": geo_item}
                self.token_payload["req"]["comparisonItem"].append(payload)

        self.token_payload["req"] = json.dumps(self.token_payload["req"])
        self._tokens()

    def _tokens(self) -> None:
        widget_dicts = self._get_data(
            url=ep.EXPLORE,
            method="post",
            params=self.token_payload,
            trim_chars=4,
        )["widgets"]
        first_region_token = True
        self.related_queries_widget_list.clear()
        self.related_topics_widget_list.clear()
        for widget in widget_dicts:
            if widget["id"] == "TIMESERIES":
                self.interest_over_time_widget = widget
            if widget["id"] == "GEO_MAP" and first_region_token:
                self.interest_by_region_widget = widget
                first_region_token = False
            if "RELATED_TOPICS" in widget["id"]:
                self.related_topics_widget_list.append(widget)
            if "RELATED_QUERIES" in widget["id"]:
                self.related_queries_widget_list.append(widget)

    def interest_over_time(self) -> dict[str, Any]:
        """Return the raw ``default`` object from the interest-over-time widget response."""
        over_time_payload = {
            "req": json.dumps(self.interest_over_time_widget["request"]),
            "token": self.interest_over_time_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.INTEREST_OVER_TIME,
            method="get",
            trim_chars=5,
            params=over_time_payload,
        )
        return req_json["default"]

    def multirange_interest_over_time(self) -> dict[str, Any]:
        """Return the raw ``default`` object from the multirange interest-over-time response."""
        over_time_payload = {
            "req": json.dumps(self.interest_over_time_widget["request"]),
            "token": self.interest_over_time_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.MULTIRANGE_INTEREST_OVER_TIME,
            method="get",
            trim_chars=5,
            params=over_time_payload,
        )
        return req_json["default"]

    def interest_by_region(
        self,
        resolution: str = "COUNTRY",
        inc_low_vol: bool = False,
        inc_geo_code: bool = False,
    ) -> dict[str, Any]:
        """Return the raw ``default`` object from the interest-by-region response."""
        g = _primary_geo(self.geo)
        if g == "":
            self.interest_by_region_widget["request"]["resolution"] = resolution
        elif g == "US" and resolution in ("DMA", "CITY", "REGION"):
            self.interest_by_region_widget["request"]["resolution"] = resolution

        self.interest_by_region_widget["request"]["includeLowSearchVolumeGeos"] = inc_low_vol

        region_payload = {
            "req": json.dumps(self.interest_by_region_widget["request"]),
            "token": self.interest_by_region_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.INTEREST_BY_REGION,
            method="get",
            trim_chars=5,
            params=region_payload,
        )
        default = req_json["default"]
        if inc_geo_code:
            if "geoMapData" in default and default["geoMapData"]:
                first = default["geoMapData"][0]
                if "geoCode" not in first and "coordinates" not in first:
                    logger.warning("Could not find geo_code column; skipping")
        return default

    @staticmethod
    def _ranked_keywords(payload: Any, index: int) -> list[dict[str, Any]] | None:
        """
        One ranked block from a related-searches response, or ``None`` when it is absent.

        Google returns ``rankedList`` with two blocks -- top and rising -- when it has data, and
        an **empty list** when it does not. Related topics now always answers that way. Indexing
        an empty list raises ``IndexError``, which the surrounding ``except KeyError`` did not
        catch, so "Google has nothing" surfaced as a crash rather than an empty result.

        ``related_queries`` had the same latent bug and only survived because Google still
        answers it with data.
        """
        try:
            return list(payload["default"]["rankedList"][index]["rankedKeyword"])
        except (KeyError, IndexError, TypeError):
            return None

    def related_topics(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
        """Per-keyword related topics: ``top`` / ``rising`` lists of ranked-keyword dicts."""
        result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
        for request_json in self.related_topics_widget_list:
            try:
                kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
            except KeyError:
                kw = ""
            related_payload = {
                "req": json.dumps(request_json["request"]),
                "token": request_json["token"],
                "tz": self.tz,
            }
            req_json = self._get_data(
                url=ep.RELATED_QUERIES,
                method="get",
                trim_chars=5,
                params=related_payload,
            )
            result_dict[kw] = {
                "top": self._ranked_keywords(req_json, 0),
                "rising": self._ranked_keywords(req_json, 1),
            }
        return result_dict

    def related_queries(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
        """Per-keyword related queries: ``top`` / ``rising`` lists of ranked-keyword dicts."""
        result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
        for request_json in self.related_queries_widget_list:
            try:
                kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
            except KeyError:
                kw = ""
            related_payload = {
                "req": json.dumps(request_json["request"]),
                "token": request_json["token"],
                "tz": self.tz,
            }
            req_json = self._get_data(
                url=ep.RELATED_QUERIES,
                method="get",
                trim_chars=5,
                params=related_payload,
            )
            top_list = self._ranked_keywords(req_json, 0)
            rising_list = self._ranked_keywords(req_json, 1)
            result_dict[kw] = {"top": top_list, "rising": rising_list}
        return result_dict

    def trending_searches(self, geo: str = "Worldwide", window: int = 8) -> list[list[Any]]:
        """
        Trending searches for ``geo``, via the ``batchexecute`` RPC.

        ``geo`` is ``"Worldwide"`` or a country code such as ``"US"``. Returns raw
        ``[term, growth_percent, volume_index]`` rows.
        """
        return self._rpc.trending_searches(geo, window)

    def geo_list(self) -> Any:
        """The full geo hierarchy Google's own region picker is built from."""
        return self._rpc.geo_list()

    def suggestions(self, query: str) -> list[list[Any]]:
        """Entity suggestions for a partial query."""
        return self._rpc.suggestions(query)

    @property
    def rpc_client(self) -> BatchExecuteClient:
        """The RPC client, for building a trending provider around."""
        return self._rpc

    @property
    def rss_client(self) -> TrendingRssClient:
        """The RSS client, for building a trending provider around."""
        return self._rss

    def today_searches(self, pn: str = "US") -> list[str]:
        """Today's search titles for ``pn`` (country code)."""
        forms = {"ns": 15, "geo": pn, "tz": "-180", "hl": self.hl}
        req_json = self._get_data(
            url=ep.TODAY_SEARCHES,
            method="get",
            trim_chars=5,
            params=forms,
            **self.requests_args,
        )["default"]["trendingSearchesDays"][0]["trendingSearches"]
        return [str(trend["title"]) for trend in req_json]

    def realtime_trending_searches(
        self,
        pn: str = "US",
        cat: str = "all",
        count: int = 300,
    ) -> list[dict[str, Any]]:
        ri_value = min(300, count)
        rs_value = min(200, count - 1) if count < 200 else 200
        forms = {
            "ns": 15,
            "geo": pn,
            "tz": "300",
            "hl": self.hl,
            "cat": cat,
            "fi": "0",
            "fs": "0",
            "ri": ri_value,
            "rs": rs_value,
            "sort": 0,
        }
        req_json = self._get_data(
            url=ep.REALTIME_TRENDING,
            method="get",
            trim_chars=5,
            params=forms,
        )["storySummaries"]["trendingStories"]
        wanted_keys = ("entityNames", "title")
        return [{k: ts[k] for k in ts if k in wanted_keys} for ts in req_json]

    def top_charts(
        self,
        date: int | str,
        hl: str = "en-US",
        tz: int = 300,
        geo: str = "GLOBAL",
    ) -> list[dict[str, Any]] | None:
        try:
            year = int(date)
        except (TypeError, ValueError) as e:
            raise ValueError("The date must be a year with format YYYY.") from e
        chart_payload = {"hl": hl, "tz": tz, "date": year, "geo": geo, "isMobile": False}
        req_json = self._get_data(
            url=ep.TOP_CHARTS,
            method="get",
            trim_chars=5,
            params=chart_payload,
        )
        try:
            return list(req_json["topCharts"][0]["listItems"])
        except IndexError:
            return None

    def autocomplete(self, keyword: str) -> Any:
        """
        Entity suggestions from the legacy ``api/autocomplete`` endpoint.

        Superseded by :meth:`suggestions`, which uses the RPC the current UI calls and
        returns better matches -- ``"tech"`` yields *Technology* there but *Technics*,
        *Technivorm*, *TechnoMarine* here. Kept because the endpoint still answers.
        """
        kw_param = quote(keyword)
        parameters = {"hl": self.hl}
        return self._get_data(
            url=ep.AUTOCOMPLETE_PREFIX + kw_param,
            params=parameters,
            method="get",
            trim_chars=5,
        )["default"]["topics"]

    def geo_picker(self) -> Any:
        return self._get_data(
            url=ep.GEO_PICKER,
            params={"hl": self.hl, "tz": self.tz},
            method="get",
            trim_chars=5,
        )

    def categories(self) -> Any:
        return self._get_data(
            url=ep.CATEGORY_PICKER,
            params={"hl": self.hl, "tz": self.tz},
            method="get",
            trim_chars=5,
        )

rpc_client property

The RPC client, for building a trending provider around.

rss_client property

The RSS client, for building a trending provider around.

autocomplete(keyword)

Entity suggestions from the legacy api/autocomplete endpoint.

Superseded by :meth:suggestions, which uses the RPC the current UI calls and returns better matches -- "tech" yields Technology there but Technics, Technivorm, TechnoMarine here. Kept because the endpoint still answers.

Source code in src/trendflow/_trends_http/session.py
def autocomplete(self, keyword: str) -> Any:
    """
    Entity suggestions from the legacy ``api/autocomplete`` endpoint.

    Superseded by :meth:`suggestions`, which uses the RPC the current UI calls and
    returns better matches -- ``"tech"`` yields *Technology* there but *Technics*,
    *Technivorm*, *TechnoMarine* here. Kept because the endpoint still answers.
    """
    kw_param = quote(keyword)
    parameters = {"hl": self.hl}
    return self._get_data(
        url=ep.AUTOCOMPLETE_PREFIX + kw_param,
        params=parameters,
        method="get",
        trim_chars=5,
    )["default"]["topics"]

geo_list()

The full geo hierarchy Google's own region picker is built from.

Source code in src/trendflow/_trends_http/session.py
def geo_list(self) -> Any:
    """The full geo hierarchy Google's own region picker is built from."""
    return self._rpc.geo_list()

interest_by_region(resolution='COUNTRY', inc_low_vol=False, inc_geo_code=False)

Return the raw default object from the interest-by-region response.

Source code in src/trendflow/_trends_http/session.py
def interest_by_region(
    self,
    resolution: str = "COUNTRY",
    inc_low_vol: bool = False,
    inc_geo_code: bool = False,
) -> dict[str, Any]:
    """Return the raw ``default`` object from the interest-by-region response."""
    g = _primary_geo(self.geo)
    if g == "":
        self.interest_by_region_widget["request"]["resolution"] = resolution
    elif g == "US" and resolution in ("DMA", "CITY", "REGION"):
        self.interest_by_region_widget["request"]["resolution"] = resolution

    self.interest_by_region_widget["request"]["includeLowSearchVolumeGeos"] = inc_low_vol

    region_payload = {
        "req": json.dumps(self.interest_by_region_widget["request"]),
        "token": self.interest_by_region_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.INTEREST_BY_REGION,
        method="get",
        trim_chars=5,
        params=region_payload,
    )
    default = req_json["default"]
    if inc_geo_code:
        if "geoMapData" in default and default["geoMapData"]:
            first = default["geoMapData"][0]
            if "geoCode" not in first and "coordinates" not in first:
                logger.warning("Could not find geo_code column; skipping")
    return default

interest_over_time()

Return the raw default object from the interest-over-time widget response.

Source code in src/trendflow/_trends_http/session.py
def interest_over_time(self) -> dict[str, Any]:
    """Return the raw ``default`` object from the interest-over-time widget response."""
    over_time_payload = {
        "req": json.dumps(self.interest_over_time_widget["request"]),
        "token": self.interest_over_time_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.INTEREST_OVER_TIME,
        method="get",
        trim_chars=5,
        params=over_time_payload,
    )
    return req_json["default"]

multirange_interest_over_time()

Return the raw default object from the multirange interest-over-time response.

Source code in src/trendflow/_trends_http/session.py
def multirange_interest_over_time(self) -> dict[str, Any]:
    """Return the raw ``default`` object from the multirange interest-over-time response."""
    over_time_payload = {
        "req": json.dumps(self.interest_over_time_widget["request"]),
        "token": self.interest_over_time_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.MULTIRANGE_INTEREST_OVER_TIME,
        method="get",
        trim_chars=5,
        params=over_time_payload,
    )
    return req_json["default"]

related_queries()

Per-keyword related queries: top / rising lists of ranked-keyword dicts.

Source code in src/trendflow/_trends_http/session.py
def related_queries(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
    """Per-keyword related queries: ``top`` / ``rising`` lists of ranked-keyword dicts."""
    result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
    for request_json in self.related_queries_widget_list:
        try:
            kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
        except KeyError:
            kw = ""
        related_payload = {
            "req": json.dumps(request_json["request"]),
            "token": request_json["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.RELATED_QUERIES,
            method="get",
            trim_chars=5,
            params=related_payload,
        )
        top_list = self._ranked_keywords(req_json, 0)
        rising_list = self._ranked_keywords(req_json, 1)
        result_dict[kw] = {"top": top_list, "rising": rising_list}
    return result_dict

related_topics()

Per-keyword related topics: top / rising lists of ranked-keyword dicts.

Source code in src/trendflow/_trends_http/session.py
def related_topics(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
    """Per-keyword related topics: ``top`` / ``rising`` lists of ranked-keyword dicts."""
    result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
    for request_json in self.related_topics_widget_list:
        try:
            kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
        except KeyError:
            kw = ""
        related_payload = {
            "req": json.dumps(request_json["request"]),
            "token": request_json["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.RELATED_QUERIES,
            method="get",
            trim_chars=5,
            params=related_payload,
        )
        result_dict[kw] = {
            "top": self._ranked_keywords(req_json, 0),
            "rising": self._ranked_keywords(req_json, 1),
        }
    return result_dict

reset_cookies()

Drop the cookie jar; the cached widget tokens are re-fetched by build_payload.

Source code in src/trendflow/_trends_http/session.py
def reset_cookies(self) -> None:
    """Drop the cookie jar; the cached widget tokens are re-fetched by build_payload."""
    self._http.reset_cookies()

set_proxy(proxy_url)

Pin a proxy for every subsequent request and drop the old exit IP's cookie jar.

Source code in src/trendflow/_trends_http/session.py
def set_proxy(self, proxy_url: str) -> None:
    """Pin a proxy for every subsequent request and drop the old exit IP's cookie jar."""
    self.proxies = [proxy_url]
    self._http.set_proxy(proxy_url)
    self._rpc.proxy = proxy_url
    self._rss.proxy = proxy_url

suggestions(query)

Entity suggestions for a partial query.

Source code in src/trendflow/_trends_http/session.py
def suggestions(self, query: str) -> list[list[Any]]:
    """Entity suggestions for a partial query."""
    return self._rpc.suggestions(query)

today_searches(pn='US')

Today's search titles for pn (country code).

Source code in src/trendflow/_trends_http/session.py
def today_searches(self, pn: str = "US") -> list[str]:
    """Today's search titles for ``pn`` (country code)."""
    forms = {"ns": 15, "geo": pn, "tz": "-180", "hl": self.hl}
    req_json = self._get_data(
        url=ep.TODAY_SEARCHES,
        method="get",
        trim_chars=5,
        params=forms,
        **self.requests_args,
    )["default"]["trendingSearchesDays"][0]["trendingSearches"]
    return [str(trend["title"]) for trend in req_json]

trending_searches(geo='Worldwide', window=8)

Trending searches for geo, via the batchexecute RPC.

geo is "Worldwide" or a country code such as "US". Returns raw [term, growth_percent, volume_index] rows.

Source code in src/trendflow/_trends_http/session.py
def trending_searches(self, geo: str = "Worldwide", window: int = 8) -> list[list[Any]]:
    """
    Trending searches for ``geo``, via the ``batchexecute`` RPC.

    ``geo`` is ``"Worldwide"`` or a country code such as ``"US"``. Returns raw
    ``[term, growth_percent, volume_index]`` rows.
    """
    return self._rpc.trending_searches(geo, window)

InterestByRegionResult dataclass

Regional popularity for a single keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestByRegionResult:
    """Regional popularity for a single keyword."""

    keyword: str
    resolution: Resolution
    rows: list[RegionalInterestRow]

InterestOverTimeResult dataclass

Interest over time for one or more keywords.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestOverTimeResult:
    """Interest over time for one or more keywords."""

    keywords: list[str]
    granularity: str
    points: list[TrendPoint]

    def to_dataframe(self) -> pd.DataFrame:
        """Build a pandas DataFrame with a `date` column and one column per keyword."""
        if not self.points:
            return pd.DataFrame(columns=["date", *self.keywords])
        rows: list[dict[str, Any]] = []
        for p in self.points:
            rows.append({"date": p.date, **p.scores})
        return pd.DataFrame(rows)

    def export(self, fmt: ExportFormat, path: str | Path) -> None:
        """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
        from trendflow._exporters import export_interest_over_time

        export_interest_over_time(self, fmt, Path(path))

export(fmt, path)

Write results to CSV or JSON (UTF-8) via :mod:trendflow._exporters.

Source code in src/trendflow/models.py
def export(self, fmt: ExportFormat, path: str | Path) -> None:
    """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
    from trendflow._exporters import export_interest_over_time

    export_interest_over_time(self, fmt, Path(path))

to_dataframe()

Build a pandas DataFrame with a date column and one column per keyword.

Source code in src/trendflow/models.py
def to_dataframe(self) -> pd.DataFrame:
    """Build a pandas DataFrame with a `date` column and one column per keyword."""
    if not self.points:
        return pd.DataFrame(columns=["date", *self.keywords])
    rows: list[dict[str, Any]] = []
    for p in self.points:
        rows.append({"date": p.date, **p.scores})
    return pd.DataFrame(rows)

ProxyPool

Holds proxy URLs and tracks which one is currently pinned.

Source code in src/trendflow/_proxy.py
class ProxyPool:
    """Holds proxy URLs and tracks which one is currently pinned."""

    def __init__(self, urls: Sequence[str]) -> None:
        cleaned = [url.strip() for url in urls if url and url.strip()]
        for url in cleaned:
            parsed = urlparse(url)
            if not parsed.scheme or not parsed.netloc:
                msg = f"Invalid proxy URL: {url}"
                raise ValueError(msg)
        if not cleaned:
            msg = "`proxies` was given no usable proxy URLs"
            raise ValueError(msg)
        self._urls = cleaned
        self._index = 0

    @property
    def size(self) -> int:
        return len(self._urls)

    @property
    def index(self) -> int:
        return self._index

    def current(self) -> str:
        """The proxy currently pinned for requests."""
        return self._urls[self._index]

    def advance(self) -> None:
        """Move to the next proxy, wrapping around at the end of the list."""
        self._index = (self._index + 1) % len(self._urls)

advance()

Move to the next proxy, wrapping around at the end of the list.

Source code in src/trendflow/_proxy.py
def advance(self) -> None:
    """Move to the next proxy, wrapping around at the end of the list."""
    self._index = (self._index + 1) % len(self._urls)

current()

The proxy currently pinned for requests.

Source code in src/trendflow/_proxy.py
def current(self) -> str:
    """The proxy currently pinned for requests."""
    return self._urls[self._index]

Region

Bases: StrEnum

ISO-style geo codes for Google Trends (hl / geo). Empty string is worldwide.

Source code in src/trendflow/enums.py
class Region(StrEnum):
    """ISO-style geo codes for Google Trends (`hl` / `geo`). Empty string is worldwide."""

    WORLDWIDE = ""
    US = "US"
    GB = "GB"
    DE = "DE"
    FR = "FR"
    IT = "IT"
    ES = "ES"
    CA = "CA"
    AU = "AU"
    JP = "JP"
    IN = "IN"
    BR = "BR"
    MX = "MX"
    NL = "NL"
    SE = "SE"
    PL = "PL"
    TR = "TR"

RelatedResult dataclass

Related queries for a seed keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedResult:
    """Related queries for a seed keyword."""

    top: list[RelatedQuery]
    rising: list[RelatedQuery]

Resolution

Bases: StrEnum

Granularity for regional interest breakdowns.

Source code in src/trendflow/enums.py
class Resolution(StrEnum):
    """Granularity for regional interest breakdowns."""

    COUNTRY = "COUNTRY"
    REGION = "REGION"
    CITY = "CITY"

ResponseError

Bases: Exception

The Trends endpoint returned a non-JSON or error response.

Source code in src/trendflow/_trends_http/exceptions.py
class ResponseError(Exception):
    """The Trends endpoint returned a non-JSON or error response."""

    def __init__(self, message: str, response: httpx.Response) -> None:
        super().__init__(message)
        self.response = response

    @classmethod
    def from_response(cls, response: httpx.Response) -> Self:
        message = f"The request failed: Google returned a response with code {response.status_code}"
        return cls(message, response)

RpcTrendingProvider

The batchexecute RPC: more items, growth and volume, no articles.

Source code in src/trendflow/_providers.py
class RpcTrendingProvider:
    """The ``batchexecute`` RPC: more items, growth and volume, no articles."""

    source = "rpc"

    def __init__(self, rpc: BatchExecuteClient) -> None:
        self._rpc = rpc

    def fetch(self, geo: str, window: int) -> list[TrendingItem]:
        return _parsers.trending_rows_to_items(self._rpc.trending_searches(geo, window))

RssTrendingProvider

The RSS feed: fewer items and no growth figures, but carries the news articles.

Source code in src/trendflow/_providers.py
class RssTrendingProvider:
    """The RSS feed: fewer items and no growth figures, but carries the news articles."""

    source = "rss"

    def __init__(self, rss: TrendingRssClient) -> None:
        self._rss = rss

    def fetch(self, geo: str, window: int) -> list[TrendingItem]:  # noqa: ARG002 - feed ignores window
        # The feed takes a bare country code and has no worldwide equivalent.
        items = self._rss.trending("" if geo == "Worldwide" else geo)
        return [
            TrendingItem(
                title=item.title,
                traffic=item.approx_traffic or "",
                articles=[
                    TrendingArticle(
                        title=news.title,
                        url=news.url,
                        source=news.source,
                        picture=news.picture,
                    )
                    for news in item.news
                ],
                growth=None,
                volume=None,
                started_at=item.pub_date,
            )
            for item in items
        ]

SearchProperty

Bases: StrEnum

Which Google surface to measure.

These are separate indexes, not filters over one dataset, so the same term can look very different across them -- a term may be quiet on web search and busy on YouTube. Values are only comparable within a single property.

Source code in src/trendflow/enums.py
class SearchProperty(StrEnum):
    """
    Which Google surface to measure.

    These are separate indexes, not filters over one dataset, so the same term can look very
    different across them -- a term may be quiet on web search and busy on YouTube. Values are
    only comparable within a single property.
    """

    WEB = ""
    IMAGES = "images"
    NEWS = "news"
    YOUTUBE = "youtube"
    SHOPPING = "froogle"

Timeframe

Bases: StrEnum

Time ranges accepted by Google Trends.

Named values for the presets. A custom range is a plain string of two ISO dates, "2023-01-01 2023-06-30", and every query method accepts one in place of a member.

The range chosen also decides the granularity Google returns: the hourly ranges come back in minutes, the daily ones hourly, and ALL_TIME monthly. Ask for five years and you cannot see a spike that lasted an afternoon.

Source code in src/trendflow/enums.py
class Timeframe(StrEnum):
    """
    Time ranges accepted by Google Trends.

    Named values for the presets. A custom range is a plain string of two ISO dates,
    ``"2023-01-01 2023-06-30"``, and every query method accepts one in place of a member.

    The range chosen also decides the granularity Google returns: the hourly ranges come back
    in minutes, the daily ones hourly, and ``ALL_TIME`` monthly. Ask for five years and you
    cannot see a spike that lasted an afternoon.
    """

    PAST_HOUR = "now 1-H"
    PAST_4_HOURS = "now 4-H"
    PAST_DAY = "now 1-d"
    PAST_WEEK = "now 7-d"
    PAST_MONTH = "today 1-m"
    PAST_3_MONTHS = "today 3-m"
    PAST_YEAR = "today 12-m"
    PAST_5_YEARS = "today 5-y"
    ALL_TIME = "all"

TooManyRequestsError

Bases: ResponseError

HTTP 429 from Google Trends.

Source code in src/trendflow/_trends_http/exceptions.py
class TooManyRequestsError(ResponseError):
    """HTTP 429 from Google Trends."""

    @classmethod
    def from_response(cls, response: httpx.Response) -> Self:
        message = (
            f"The request failed: Google returned a response with code {response.status_code}. "
            f"Google rate-limits by exit IP; see {RATE_LIMIT_DOCS_URL} for how to work around it."
        )
        return cls(message, response)

TopicSuggestion dataclass

An entity Google recognises, as returned by search suggestions.

mid is the identifier to pass as a keyword to query the topic rather than the literal phrase -- a topic aggregates every spelling and translation of the same concept.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TopicSuggestion:
    """
    An entity Google recognises, as returned by search suggestions.

    ``mid`` is the identifier to pass as a keyword to query the **topic** rather than the
    literal phrase -- a topic aggregates every spelling and translation of the same concept.
    """

    #: Freebase-style entity id, e.g. ``"/m/0mkz"``. Pass this as a keyword.
    mid: str
    #: Display name, e.g. ``"Artificial intelligence"``.
    title: str
    #: Disambiguating descriptor, e.g. ``"Professional field"``. ``None`` when Google omits it.
    type: str | None = None

TrendingProvider

Bases: Protocol

A source of trending searches.

Source code in src/trendflow/_providers.py
@runtime_checkable
class TrendingProvider(Protocol):
    """A source of trending searches."""

    @property
    def source(self) -> str: ...

    def fetch(self, geo: str, window: int) -> list[TrendingItem]: ...

TrendingResult dataclass

Current trending searches for a region.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingResult:
    """Current trending searches for a region."""

    results: list[TrendingItem]
    #: Which backend answered -- useful when ``backend="auto"`` picked for you.
    source: str = "rpc"

TrendsFetcher

Bases: Protocol

Strategy for retrieving Trends data (swap in tests or alternate backends).

Source code in src/trendflow/_fetcher.py
@runtime_checkable
class TrendsFetcher(Protocol):
    """Strategy for retrieving Trends data (swap in tests or alternate backends)."""

    def interest_over_time(
        self,
        keywords: list[str],
        timeframe: Timeframe | str,
        region: Region | str,
        *,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestOverTimeResult: ...

    def interest_by_region(
        self,
        keyword: str,
        resolution: Resolution,
        region: Region | str = Region.US,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> InterestByRegionResult: ...

    def trending_now(
        self,
        region: Region | str = Region.WORLDWIDE,
        window: int = ...,
        backend: TrendingBackend = ...,
    ) -> TrendingResult: ...

    def related_queries(
        self,
        keyword: str,
        *,
        timeframe: Timeframe | str = Timeframe.PAST_YEAR,
        region: Region | str = Region.WORLDWIDE,
        category: int = 0,
        search_property: SearchProperty | str = SearchProperty.WEB,
    ) -> RelatedResult: ...

    def suggestions(self, query: str) -> list[TopicSuggestion]: ...

UnknownRpcError

Bases: Exception

Google returned no frame for the RPC that was called.

That is the signature of an identifier renamed on Google's side.

Source code in src/trendflow/_trends_http/batchexecute.py
class UnknownRpcError(Exception):
    """
    Google returned no frame for the RPC that was called.

    That is the signature of an identifier renamed on Google's side.
    """

    def __init__(self, rpc_id: str) -> None:
        super().__init__(
            f"Google returned no data for RPC {rpc_id!r}. This usually means the RPC "
            f"identifier changed on Google's side. Override it with the `rpc_ids` argument, "
            f"and please open an issue at https://github.com/dariomory/trendflow/issues",
        )
        self.rpc_id = rpc_id

_gprop(value)

Narrow a search property to the literal the session accepts.

The session validates as well, but only once a payload is being built. Checking here means a mistyped plain string fails immediately with the allowed values, rather than surfacing later as something that looks like a network problem.

Source code in src/trendflow/_fetcher.py
def _gprop(value: SearchProperty | str) -> Gprop:
    """
    Narrow a search property to the literal the session accepts.

    The session validates as well, but only once a payload is being built. Checking here means
    a mistyped plain string fails immediately with the allowed values, rather than surfacing
    later as something that looks like a network problem.
    """
    text = str(value)
    if text not in _SEARCH_PROPERTIES:
        allowed = ", ".join(repr(p) for p in _SEARCH_PROPERTIES)
        msg = f"search_property must be one of {allowed}; got {text!r}"
        raise ValueError(msg)
    return cast("Gprop", text)

_hl_from_language(language)

Source code in src/trendflow/_fetcher.py
def _hl_from_language(language: str) -> str:
    if "-" in language:
        return language
    return f"{language}-US"

_param(value)

The wire value for a geo or timeframe.

These are StrEnums, so a member already is its string. Going through str rather than .value is what lets callers pass a plain string -- a custom date range, or one of the ~250 region codes and sub-regions that are not enum members.

Source code in src/trendflow/_fetcher.py
def _param(value: object) -> str:
    """
    The wire value for a geo or timeframe.

    These are ``StrEnum``s, so a member already *is* its string. Going through ``str`` rather
    than ``.value`` is what lets callers pass a plain string -- a custom date range, or one of
    the ~250 region codes and sub-regions that are not enum members.
    """
    return str(value)

_should_rotate(error)

Whether a failure is worth retrying on a different exit IP.

A 429 or a network error says "this IP is blocked". A 404 says the endpoint is gone, and a renamed RPC id fails identically everywhere, so rotating would only burn the pool.

Source code in src/trendflow/_fetcher.py
def _should_rotate(error: Exception) -> bool:
    """
    Whether a failure is worth retrying on a different exit IP.

    A 429 or a network error says "this IP is blocked". A 404 says the endpoint is gone, and a
    renamed RPC id fails identically everywhere, so rotating would only burn the pool.
    """
    if isinstance(error, TooManyRequestsError):
        return True
    if isinstance(error, ResponseError):
        return error.response.status_code == HTTP_FORBIDDEN
    if isinstance(error, UnknownRpcError):
        return False
    return True

Google's RPC takes the literal "Worldwide" rather than an empty geo.

Source code in src/trendflow/_fetcher.py
def _trending_geo(region: Region | str) -> str:
    """Google's RPC takes the literal ``"Worldwide"`` rather than an empty geo."""
    value = region.value if isinstance(region, Region) else str(region)
    return "Worldwide" if value == "" else value

trendflow._parsers

InterestByRegionResult dataclass

Regional popularity for a single keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestByRegionResult:
    """Regional popularity for a single keyword."""

    keyword: str
    resolution: Resolution
    rows: list[RegionalInterestRow]

InterestOverTimeResult dataclass

Interest over time for one or more keywords.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestOverTimeResult:
    """Interest over time for one or more keywords."""

    keywords: list[str]
    granularity: str
    points: list[TrendPoint]

    def to_dataframe(self) -> pd.DataFrame:
        """Build a pandas DataFrame with a `date` column and one column per keyword."""
        if not self.points:
            return pd.DataFrame(columns=["date", *self.keywords])
        rows: list[dict[str, Any]] = []
        for p in self.points:
            rows.append({"date": p.date, **p.scores})
        return pd.DataFrame(rows)

    def export(self, fmt: ExportFormat, path: str | Path) -> None:
        """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
        from trendflow._exporters import export_interest_over_time

        export_interest_over_time(self, fmt, Path(path))

export(fmt, path)

Write results to CSV or JSON (UTF-8) via :mod:trendflow._exporters.

Source code in src/trendflow/models.py
def export(self, fmt: ExportFormat, path: str | Path) -> None:
    """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
    from trendflow._exporters import export_interest_over_time

    export_interest_over_time(self, fmt, Path(path))

to_dataframe()

Build a pandas DataFrame with a date column and one column per keyword.

Source code in src/trendflow/models.py
def to_dataframe(self) -> pd.DataFrame:
    """Build a pandas DataFrame with a `date` column and one column per keyword."""
    if not self.points:
        return pd.DataFrame(columns=["date", *self.keywords])
    rows: list[dict[str, Any]] = []
    for p in self.points:
        rows.append({"date": p.date, **p.scores})
    return pd.DataFrame(rows)

RegionalInterestRow dataclass

One region row from interest-by-region.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RegionalInterestRow:
    """One region row from interest-by-region."""

    label: str
    value: int

RelatedQuery dataclass

A top or rising related query.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedQuery:
    """A top or rising related query."""

    term: str
    value: int | None = None
    breakout: str | None = None

RelatedResult dataclass

Related queries for a seed keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedResult:
    """Related queries for a seed keyword."""

    top: list[RelatedQuery]
    rising: list[RelatedQuery]

Resolution

Bases: StrEnum

Granularity for regional interest breakdowns.

Source code in src/trendflow/enums.py
class Resolution(StrEnum):
    """Granularity for regional interest breakdowns."""

    COUNTRY = "COUNTRY"
    REGION = "REGION"
    CITY = "CITY"

TopicSuggestion dataclass

An entity Google recognises, as returned by search suggestions.

mid is the identifier to pass as a keyword to query the topic rather than the literal phrase -- a topic aggregates every spelling and translation of the same concept.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TopicSuggestion:
    """
    An entity Google recognises, as returned by search suggestions.

    ``mid`` is the identifier to pass as a keyword to query the **topic** rather than the
    literal phrase -- a topic aggregates every spelling and translation of the same concept.
    """

    #: Freebase-style entity id, e.g. ``"/m/0mkz"``. Pass this as a keyword.
    mid: str
    #: Display name, e.g. ``"Artificial intelligence"``.
    title: str
    #: Disambiguating descriptor, e.g. ``"Professional field"``. ``None`` when Google omits it.
    type: str | None = None

TrendPoint dataclass

One timestamp in an interest-over-time series.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendPoint:
    """One timestamp in an interest-over-time series."""

    date: datetime
    scores: dict[str, int]

TrendingItem dataclass

A single trending search entry.

Both backends fill title and traffic; the rest depends on which one answered, since Google exposes different fields on each. See :attr:TrendingResult.source.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingItem:
    """
    A single trending search entry.

    Both backends fill ``title`` and ``traffic``; the rest depends on which one answered,
    since Google exposes different fields on each. See :attr:`TrendingResult.source`.
    """

    title: str
    #: Human-readable traffic: ``"+3,950%"`` from the RPC, ``"2000+"`` from RSS.
    traffic: str
    #: News articles behind the trend. RSS backend only; empty from the RPC.
    articles: list[TrendingArticle] = field(default_factory=list)
    #: Percentage increase over the window, e.g. ``3950``. RPC backend only.
    growth: int | None = None
    #: Relative search volume, on Google's own 0-100 style scale. RPC backend only.
    volume: int | None = None
    #: When Google started reporting the trend. RSS backend only.
    started_at: datetime | None = None

_format_growth(growth)

Source code in src/trendflow/_parsers.py
def _format_growth(growth: int | None) -> str:
    if growth is None:
        return ""
    sign = "+" if growth >= 0 else "-"
    return f"{sign}{abs(growth):,}%"

_is_missing_value(val)

Source code in src/trendflow/_parsers.py
def _is_missing_value(val: Any) -> bool:
    if val is None:
        return True
    return isinstance(val, float) and math.isnan(val)

_mapping(value)

A mapping to read, whatever Google sent. See :func:_rows.

Source code in src/trendflow/_parsers.py
def _mapping(value: Any) -> Mapping[str, Any]:
    """A mapping to read, whatever Google sent. See :func:`_rows`."""
    return value if isinstance(value, Mapping) else {}

_rows(value)

A list to iterate, whatever Google sent.

Every parser below reads a positional structure that Google owns and can change without notice. Individual rows were already guarded; the containers were not, so a null where a list was expected raised out of the parser and became an error for the caller. Junk in, empty out -- never a throw.

Source code in src/trendflow/_parsers.py
def _rows(value: Any) -> list[Any]:
    """
    A list to iterate, whatever Google sent.

    Every parser below reads a positional structure that Google owns and can change without
    notice. Individual rows were already guarded; the containers were not, so a ``null`` where
    a list was expected raised out of the parser and became an error for the caller. Junk in,
    empty out -- never a throw.
    """
    return value if isinstance(value, list) else []

_split_bracketed_ints(value)

Source code in src/trendflow/_parsers.py
def _split_bracketed_ints(value: Any) -> list[int]:
    raw = str(value).replace("[", "").replace("]", "").split(",")
    return [int(x.strip()) for x in raw if x.strip()]

_timestamp(entry)

The epoch seconds on a timeline entry, or None if it does not carry a usable one.

Source code in src/trendflow/_parsers.py
def _timestamp(entry: Any) -> float | None:
    """The epoch seconds on a timeline entry, or None if it does not carry a usable one."""
    raw = _mapping(entry).get("time")
    if not isinstance(raw, (str, int, float)) or isinstance(raw, bool):
        return None
    try:
        return float(raw)
    except ValueError:
        return None

_to_int_or_none(val)

Source code in src/trendflow/_parsers.py
def _to_int_or_none(val: Any) -> int | None:
    if _is_missing_value(val):
        return None
    if isinstance(val, bool):
        return int(val)
    if isinstance(val, int):
        return val
    if isinstance(val, float):
        return int(val)
    try:
        return int(val)
    except (TypeError, ValueError):
        return None

infer_granularity(d0, d1)

Source code in src/trendflow/_parsers.py
def infer_granularity(d0: datetime, d1: datetime) -> str:
    delta = d1 - d0
    days = delta.days
    if days >= 6:
        return "weekly"
    if days >= 1:
        return "daily"
    return "hourly"

interest_by_region_rows(default, keyword, kw_list)

Rows from geoMapData for keyword (index in kw_list selects the value column).

Source code in src/trendflow/_parsers.py
def interest_by_region_rows(default: Any, keyword: str, kw_list: list[str]) -> list[RegionalInterestRow]:
    """Rows from ``geoMapData`` for ``keyword`` (index in ``kw_list`` selects the value column)."""
    idx = kw_list.index(keyword) if keyword in kw_list else 0
    rows: list[RegionalInterestRow] = []
    for item in _rows(_mapping(default).get("geoMapData")):
        label = str(_mapping(item).get("geoName", ""))
        vals = _split_bracketed_ints(_mapping(item).get("value", ""))
        val = vals[idx] if idx < len(vals) else 0
        rows.append(RegionalInterestRow(label=label, value=val))
    return rows

interest_by_region_to_result(default, keyword, kw_list, resolution)

Source code in src/trendflow/_parsers.py
def interest_by_region_to_result(
    default: Any,
    keyword: str,
    kw_list: list[str],
    resolution: Resolution,
) -> InterestByRegionResult:
    rows = interest_by_region_rows(default, keyword, kw_list)
    return InterestByRegionResult(keyword=keyword, resolution=resolution, rows=rows)

interest_over_time_to_result(default, keywords, geo)

Build :class:InterestOverTimeResult from a widget default object (timelineData).

Source code in src/trendflow/_parsers.py
def interest_over_time_to_result(
    default: Any,
    keywords: list[str],
    geo: str | list[str],
) -> InterestOverTimeResult:
    """Build :class:`InterestOverTimeResult` from a widget ``default`` object (``timelineData``)."""
    geo_list = geo if isinstance(geo, list) else [geo]
    # Paired with the timestamp up front: an entry with no usable `time` cannot be placed on an
    # axis, and dropping it beats discarding the whole series over one malformed point.
    timeline = [
        (ts, entry) for entry in _rows(_mapping(default).get("timelineData")) if (ts := _timestamp(entry)) is not None
    ]
    if not timeline:
        return InterestOverTimeResult(keywords=keywords, granularity="unknown", points=[])

    if len(timeline) < 2:
        granularity = "unknown"
    else:
        granularity = infer_granularity(
            datetime.fromtimestamp(timeline[0][0]),
            datetime.fromtimestamp(timeline[1][0]),
        )

    points: list[TrendPoint] = []
    for ts, entry in timeline:
        dt = datetime.fromtimestamp(ts)
        vals = _split_bracketed_ints(_mapping(entry).get("value", ""))
        scores: dict[str, int] = {}
        for j, (kw, g) in enumerate(product(keywords, geo_list)):
            if j >= len(vals):
                break
            if len(geo_list) == 1:
                scores[kw] = vals[j]
            else:
                scores[f"{kw}|{g}"] = vals[j]
        points.append(TrendPoint(date=dt, scores=scores))

    return InterestOverTimeResult(keywords=keywords, granularity=granularity, points=points)
Source code in src/trendflow/_parsers.py
def parse_rising_related(rows: Any) -> list[RelatedQuery]:
    out: list[RelatedQuery] = []
    for row in _rows(rows):
        cells = _mapping(row)
        term = str(cells.get("query", ""))
        breakout = cells.get("formattedValue", cells.get("value"))
        if _is_missing_value(breakout):
            bstr = None
        else:
            bstr = str(breakout)
        out.append(RelatedQuery(term=term, breakout=bstr))
    return out
Source code in src/trendflow/_parsers.py
def parse_top_related(rows: Any) -> list[RelatedQuery]:
    out: list[RelatedQuery] = []
    for row in _rows(rows):
        term = str(_mapping(row).get("query", ""))
        val = _mapping(row).get("value")
        out.append(RelatedQuery(term=term, value=_to_int_or_none(val)))
    return out

related_queries_to_result(raw, keyword)

Pick the bucket for keyword, or the sole bucket if only one series exists.

Source code in src/trendflow/_parsers.py
def related_queries_to_result(
    raw: Any,
    keyword: str,
) -> RelatedResult:
    """Pick the bucket for ``keyword``, or the sole bucket if only one series exists."""
    buckets = _mapping(raw)
    if not buckets:
        return RelatedResult(top=[], rising=[])
    if keyword in buckets:
        part = buckets[keyword]
    elif len(buckets) == 1:
        part = next(iter(buckets.values()))
    else:
        return RelatedResult(top=[], rising=[])
    cells = _mapping(part)
    return RelatedResult(
        top=parse_top_related(cells.get("top")),
        rising=parse_rising_related(cells.get("rising")),
    )

suggestion_rows_to_topics(rows)

Map [mid, title, type, ...] rows from the suggestions RPC to topics.

Source code in src/trendflow/_parsers.py
def suggestion_rows_to_topics(rows: Any) -> list[TopicSuggestion]:
    """Map ``[mid, title, type, ...]`` rows from the suggestions RPC to topics."""
    out: list[TopicSuggestion] = []
    for row in _rows(rows):
        if not isinstance(row, list) or not row or not isinstance(row[0], str):
            continue
        raw_type = row[2] if len(row) > 2 else None
        topic_type = raw_type if isinstance(raw_type, str) and raw_type else None
        out.append(
            TopicSuggestion(
                mid=row[0],
                title=str(row[1]) if len(row) > 1 and row[1] is not None else "",
                type=topic_type,
            ),
        )
    return out

trending_rows_to_items(rows)

Map [term, growth_percent, volume_index] rows from the trending RPC to items.

Source code in src/trendflow/_parsers.py
def trending_rows_to_items(rows: Any) -> list[TrendingItem]:
    """Map ``[term, growth_percent, volume_index]`` rows from the trending RPC to items."""
    items: list[TrendingItem] = []
    for row in _rows(rows):
        if not isinstance(row, list) or not row:
            continue
        growth = _to_int_or_none(row[1]) if len(row) > 1 else None
        items.append(
            TrendingItem(
                title=str(row[0]),
                traffic=_format_growth(growth),
                # The RPC carries neither articles nor a start time; RSS supplies those.
                articles=[],
                growth=growth,
                volume=_to_int_or_none(row[2]) if len(row) > 2 else None,
                started_at=None,
            ),
        )
    return items

trendflow.models

ExportFormat

Bases: StrEnum

Supported export targets for tabular trend data.

Source code in src/trendflow/enums.py
class ExportFormat(StrEnum):
    """Supported export targets for tabular trend data."""

    CSV = "csv"
    JSON = "json"

InterestByRegionResult dataclass

Regional popularity for a single keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestByRegionResult:
    """Regional popularity for a single keyword."""

    keyword: str
    resolution: Resolution
    rows: list[RegionalInterestRow]

InterestOverTimeResult dataclass

Interest over time for one or more keywords.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class InterestOverTimeResult:
    """Interest over time for one or more keywords."""

    keywords: list[str]
    granularity: str
    points: list[TrendPoint]

    def to_dataframe(self) -> pd.DataFrame:
        """Build a pandas DataFrame with a `date` column and one column per keyword."""
        if not self.points:
            return pd.DataFrame(columns=["date", *self.keywords])
        rows: list[dict[str, Any]] = []
        for p in self.points:
            rows.append({"date": p.date, **p.scores})
        return pd.DataFrame(rows)

    def export(self, fmt: ExportFormat, path: str | Path) -> None:
        """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
        from trendflow._exporters import export_interest_over_time

        export_interest_over_time(self, fmt, Path(path))

export(fmt, path)

Write results to CSV or JSON (UTF-8) via :mod:trendflow._exporters.

Source code in src/trendflow/models.py
def export(self, fmt: ExportFormat, path: str | Path) -> None:
    """Write results to CSV or JSON (UTF-8) via :mod:`trendflow._exporters`."""
    from trendflow._exporters import export_interest_over_time

    export_interest_over_time(self, fmt, Path(path))

to_dataframe()

Build a pandas DataFrame with a date column and one column per keyword.

Source code in src/trendflow/models.py
def to_dataframe(self) -> pd.DataFrame:
    """Build a pandas DataFrame with a `date` column and one column per keyword."""
    if not self.points:
        return pd.DataFrame(columns=["date", *self.keywords])
    rows: list[dict[str, Any]] = []
    for p in self.points:
        rows.append({"date": p.date, **p.scores})
    return pd.DataFrame(rows)

RegionalInterestRow dataclass

One region row from interest-by-region.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RegionalInterestRow:
    """One region row from interest-by-region."""

    label: str
    value: int

RelatedQuery dataclass

A top or rising related query.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedQuery:
    """A top or rising related query."""

    term: str
    value: int | None = None
    breakout: str | None = None

RelatedResult dataclass

Related queries for a seed keyword.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class RelatedResult:
    """Related queries for a seed keyword."""

    top: list[RelatedQuery]
    rising: list[RelatedQuery]

Resolution

Bases: StrEnum

Granularity for regional interest breakdowns.

Source code in src/trendflow/enums.py
class Resolution(StrEnum):
    """Granularity for regional interest breakdowns."""

    COUNTRY = "COUNTRY"
    REGION = "REGION"
    CITY = "CITY"

TopicSuggestion dataclass

An entity Google recognises, as returned by search suggestions.

mid is the identifier to pass as a keyword to query the topic rather than the literal phrase -- a topic aggregates every spelling and translation of the same concept.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TopicSuggestion:
    """
    An entity Google recognises, as returned by search suggestions.

    ``mid`` is the identifier to pass as a keyword to query the **topic** rather than the
    literal phrase -- a topic aggregates every spelling and translation of the same concept.
    """

    #: Freebase-style entity id, e.g. ``"/m/0mkz"``. Pass this as a keyword.
    mid: str
    #: Display name, e.g. ``"Artificial intelligence"``.
    title: str
    #: Disambiguating descriptor, e.g. ``"Professional field"``. ``None`` when Google omits it.
    type: str | None = None

TrendPoint dataclass

One timestamp in an interest-over-time series.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendPoint:
    """One timestamp in an interest-over-time series."""

    date: datetime
    scores: dict[str, int]

TrendingArticle dataclass

A news article behind a trending search. Only the RSS backend reports these.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingArticle:
    """A news article behind a trending search. Only the RSS backend reports these."""

    title: str
    url: str
    source: str
    picture: str | None = None

TrendingItem dataclass

A single trending search entry.

Both backends fill title and traffic; the rest depends on which one answered, since Google exposes different fields on each. See :attr:TrendingResult.source.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingItem:
    """
    A single trending search entry.

    Both backends fill ``title`` and ``traffic``; the rest depends on which one answered,
    since Google exposes different fields on each. See :attr:`TrendingResult.source`.
    """

    title: str
    #: Human-readable traffic: ``"+3,950%"`` from the RPC, ``"2000+"`` from RSS.
    traffic: str
    #: News articles behind the trend. RSS backend only; empty from the RPC.
    articles: list[TrendingArticle] = field(default_factory=list)
    #: Percentage increase over the window, e.g. ``3950``. RPC backend only.
    growth: int | None = None
    #: Relative search volume, on Google's own 0-100 style scale. RPC backend only.
    volume: int | None = None
    #: When Google started reporting the trend. RSS backend only.
    started_at: datetime | None = None

TrendingResult dataclass

Current trending searches for a region.

Source code in src/trendflow/models.py
@dataclass(frozen=True)
class TrendingResult:
    """Current trending searches for a region."""

    results: list[TrendingItem]
    #: Which backend answered -- useful when ``backend="auto"`` picked for you.
    source: str = "rpc"

trendflow.enums

ExportFormat

Bases: StrEnum

Supported export targets for tabular trend data.

Source code in src/trendflow/enums.py
class ExportFormat(StrEnum):
    """Supported export targets for tabular trend data."""

    CSV = "csv"
    JSON = "json"

Region

Bases: StrEnum

ISO-style geo codes for Google Trends (hl / geo). Empty string is worldwide.

Source code in src/trendflow/enums.py
class Region(StrEnum):
    """ISO-style geo codes for Google Trends (`hl` / `geo`). Empty string is worldwide."""

    WORLDWIDE = ""
    US = "US"
    GB = "GB"
    DE = "DE"
    FR = "FR"
    IT = "IT"
    ES = "ES"
    CA = "CA"
    AU = "AU"
    JP = "JP"
    IN = "IN"
    BR = "BR"
    MX = "MX"
    NL = "NL"
    SE = "SE"
    PL = "PL"
    TR = "TR"

Resolution

Bases: StrEnum

Granularity for regional interest breakdowns.

Source code in src/trendflow/enums.py
class Resolution(StrEnum):
    """Granularity for regional interest breakdowns."""

    COUNTRY = "COUNTRY"
    REGION = "REGION"
    CITY = "CITY"

SearchProperty

Bases: StrEnum

Which Google surface to measure.

These are separate indexes, not filters over one dataset, so the same term can look very different across them -- a term may be quiet on web search and busy on YouTube. Values are only comparable within a single property.

Source code in src/trendflow/enums.py
class SearchProperty(StrEnum):
    """
    Which Google surface to measure.

    These are separate indexes, not filters over one dataset, so the same term can look very
    different across them -- a term may be quiet on web search and busy on YouTube. Values are
    only comparable within a single property.
    """

    WEB = ""
    IMAGES = "images"
    NEWS = "news"
    YOUTUBE = "youtube"
    SHOPPING = "froogle"

Timeframe

Bases: StrEnum

Time ranges accepted by Google Trends.

Named values for the presets. A custom range is a plain string of two ISO dates, "2023-01-01 2023-06-30", and every query method accepts one in place of a member.

The range chosen also decides the granularity Google returns: the hourly ranges come back in minutes, the daily ones hourly, and ALL_TIME monthly. Ask for five years and you cannot see a spike that lasted an afternoon.

Source code in src/trendflow/enums.py
class Timeframe(StrEnum):
    """
    Time ranges accepted by Google Trends.

    Named values for the presets. A custom range is a plain string of two ISO dates,
    ``"2023-01-01 2023-06-30"``, and every query method accepts one in place of a member.

    The range chosen also decides the granularity Google returns: the hourly ranges come back
    in minutes, the daily ones hourly, and ``ALL_TIME`` monthly. Ask for five years and you
    cannot see a spike that lasted an afternoon.
    """

    PAST_HOUR = "now 1-H"
    PAST_4_HOURS = "now 4-H"
    PAST_DAY = "now 1-d"
    PAST_WEEK = "now 7-d"
    PAST_MONTH = "today 1-m"
    PAST_3_MONTHS = "today 3-m"
    PAST_YEAR = "today 12-m"
    PAST_5_YEARS = "today 5-y"
    ALL_TIME = "all"

Google Trends internal JSON API client.

Split for maintainability: :mod:~trendflow._trends_http.endpoints (URLs), :mod:~trendflow._trends_http.exceptions, :mod:~trendflow._trends_http.transport (HTTP + cookies), :mod:~trendflow._trends_http.session (state + raw JSON).

Browser UIs may POST extra JSON to /api/explore; this library uses query-parameter POSTs for tokens.

Stateful client for Google Trends internal APIs (explore + widgetdata).

Composes :class:TrendsJsonTransport for HTTP; this class holds comparison state and returns raw JSON for callers to parse (e.g. :mod:trendflow._parsers).

Source code in src/trendflow/_trends_http/session.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
class GoogleTrendsHttpSession:
    """
    Stateful client for Google Trends internal APIs (explore + widgetdata).

    Composes :class:`TrendsJsonTransport` for HTTP; this class holds comparison
    state and returns raw JSON for callers to parse (e.g. :mod:`trendflow._parsers`).
    """

    def __init__(
        self,
        hl: str = "en-US",
        tz: int = 360,
        geo: str = "",
        timeout: httpx.Timeout | tuple[float, float] | float = (2, 5),
        proxies: str | Sequence[str] = "",
        retries: int = 0,
        backoff_factor: float = 0,
        requests_args: Mapping[str, Any] | None = None,
        rpc_ids: Mapping[str, str] | None = None,
    ) -> None:
        self.tz = tz
        self.hl = hl
        self.geo: str | list[str] = geo
        self.kw_list: list[str] = []
        self.timeout = timeout
        self.proxies = _normalize_proxies(proxies)
        self.retries = retries
        self.backoff_factor = backoff_factor
        self.requests_args: dict[str, Any] = dict(requests_args or {})
        self.results: Any = None

        headers: MutableMapping[str, str] = {
            "accept": "application/json, text/plain, */*",
            "accept-language": self.hl,
            "user-agent": DEFAULT_USER_AGENT,
            "origin": "https://trends.google.com",
            "referer": f"{ep.BASE_TRENDS_URL}/explore",
        }
        headers.update(self.requests_args.pop("headers", {}))

        self._http = TrendsJsonTransport(
            hl=self.hl,
            tz=self.tz,
            timeout=self.timeout,
            headers=headers,
            extra_client_args=self.requests_args,
            proxy_urls=self.proxies,
            retries=self.retries,
        )

        self._rpc = BatchExecuteClient(
            hl=self.hl,
            timeout=self.timeout,
            headers=dict(headers),
            rpc_ids=rpc_ids,
            proxy=self.proxies[0] if self.proxies else None,
        )

        self._rss = TrendingRssClient(
            timeout=self.timeout,
            headers=dict(headers),
            proxy=self.proxies[0] if self.proxies else None,
        )

        self.token_payload: dict[str, Any] = {}
        self.interest_over_time_widget: dict[str, Any] = {}
        self.interest_by_region_widget: dict[str, Any] = {}
        self.related_topics_widget_list: list[dict[str, Any]] = []
        self.related_queries_widget_list: list[dict[str, Any]] = []

    @property
    def proxy_index(self) -> int:
        return self._http._proxy_index

    def set_proxy(self, proxy_url: str) -> None:
        """Pin a proxy for every subsequent request and drop the old exit IP's cookie jar."""
        self.proxies = [proxy_url]
        self._http.set_proxy(proxy_url)
        self._rpc.proxy = proxy_url
        self._rss.proxy = proxy_url

    def reset_cookies(self) -> None:
        """Drop the cookie jar; the cached widget tokens are re-fetched by build_payload."""
        self._http.reset_cookies()

    @property
    def cookies(self) -> dict[str, str]:
        return self._http.cookies

    @cookies.setter
    def cookies(self, value: dict[str, str]) -> None:
        self._http.cookies = value

    def _get_data(self, url: str, method: Literal["get", "post"] = "get", trim_chars: int = 0, **kwargs: Any) -> Any:
        return self._http.request_json(url, method, trim_chars=trim_chars, **kwargs)

    def build_payload(
        self,
        kw_list: list[str],
        cat: int = 0,
        timeframe: str | list[str] = "today 5-y",
        geo: str = "",
        gprop: Gprop = "",
    ) -> None:
        allowed: tuple[str, ...] = ("", "images", "news", "youtube", "froogle")
        if gprop not in allowed:
            raise ValueError(
                "gprop must be empty (web), images, news, youtube, or froogle",
            )
        self.kw_list = kw_list
        self.geo = geo or self.geo
        self.token_payload = {
            "hl": self.hl,
            "tz": self.tz,
            "req": {"comparisonItem": [], "category": cat, "property": gprop},
        }

        if not isinstance(self.geo, list):
            self.geo = [self.geo]

        if isinstance(timeframe, list):
            for index, (kw, geo_item) in enumerate(product(self.kw_list, self.geo)):
                payload = {"keyword": kw, "time": timeframe[index], "geo": geo_item}
                self.token_payload["req"]["comparisonItem"].append(payload)
        else:
            for kw, geo_item in product(self.kw_list, self.geo):
                payload = {"keyword": kw, "time": timeframe, "geo": geo_item}
                self.token_payload["req"]["comparisonItem"].append(payload)

        self.token_payload["req"] = json.dumps(self.token_payload["req"])
        self._tokens()

    def _tokens(self) -> None:
        widget_dicts = self._get_data(
            url=ep.EXPLORE,
            method="post",
            params=self.token_payload,
            trim_chars=4,
        )["widgets"]
        first_region_token = True
        self.related_queries_widget_list.clear()
        self.related_topics_widget_list.clear()
        for widget in widget_dicts:
            if widget["id"] == "TIMESERIES":
                self.interest_over_time_widget = widget
            if widget["id"] == "GEO_MAP" and first_region_token:
                self.interest_by_region_widget = widget
                first_region_token = False
            if "RELATED_TOPICS" in widget["id"]:
                self.related_topics_widget_list.append(widget)
            if "RELATED_QUERIES" in widget["id"]:
                self.related_queries_widget_list.append(widget)

    def interest_over_time(self) -> dict[str, Any]:
        """Return the raw ``default`` object from the interest-over-time widget response."""
        over_time_payload = {
            "req": json.dumps(self.interest_over_time_widget["request"]),
            "token": self.interest_over_time_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.INTEREST_OVER_TIME,
            method="get",
            trim_chars=5,
            params=over_time_payload,
        )
        return req_json["default"]

    def multirange_interest_over_time(self) -> dict[str, Any]:
        """Return the raw ``default`` object from the multirange interest-over-time response."""
        over_time_payload = {
            "req": json.dumps(self.interest_over_time_widget["request"]),
            "token": self.interest_over_time_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.MULTIRANGE_INTEREST_OVER_TIME,
            method="get",
            trim_chars=5,
            params=over_time_payload,
        )
        return req_json["default"]

    def interest_by_region(
        self,
        resolution: str = "COUNTRY",
        inc_low_vol: bool = False,
        inc_geo_code: bool = False,
    ) -> dict[str, Any]:
        """Return the raw ``default`` object from the interest-by-region response."""
        g = _primary_geo(self.geo)
        if g == "":
            self.interest_by_region_widget["request"]["resolution"] = resolution
        elif g == "US" and resolution in ("DMA", "CITY", "REGION"):
            self.interest_by_region_widget["request"]["resolution"] = resolution

        self.interest_by_region_widget["request"]["includeLowSearchVolumeGeos"] = inc_low_vol

        region_payload = {
            "req": json.dumps(self.interest_by_region_widget["request"]),
            "token": self.interest_by_region_widget["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.INTEREST_BY_REGION,
            method="get",
            trim_chars=5,
            params=region_payload,
        )
        default = req_json["default"]
        if inc_geo_code:
            if "geoMapData" in default and default["geoMapData"]:
                first = default["geoMapData"][0]
                if "geoCode" not in first and "coordinates" not in first:
                    logger.warning("Could not find geo_code column; skipping")
        return default

    @staticmethod
    def _ranked_keywords(payload: Any, index: int) -> list[dict[str, Any]] | None:
        """
        One ranked block from a related-searches response, or ``None`` when it is absent.

        Google returns ``rankedList`` with two blocks -- top and rising -- when it has data, and
        an **empty list** when it does not. Related topics now always answers that way. Indexing
        an empty list raises ``IndexError``, which the surrounding ``except KeyError`` did not
        catch, so "Google has nothing" surfaced as a crash rather than an empty result.

        ``related_queries`` had the same latent bug and only survived because Google still
        answers it with data.
        """
        try:
            return list(payload["default"]["rankedList"][index]["rankedKeyword"])
        except (KeyError, IndexError, TypeError):
            return None

    def related_topics(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
        """Per-keyword related topics: ``top`` / ``rising`` lists of ranked-keyword dicts."""
        result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
        for request_json in self.related_topics_widget_list:
            try:
                kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
            except KeyError:
                kw = ""
            related_payload = {
                "req": json.dumps(request_json["request"]),
                "token": request_json["token"],
                "tz": self.tz,
            }
            req_json = self._get_data(
                url=ep.RELATED_QUERIES,
                method="get",
                trim_chars=5,
                params=related_payload,
            )
            result_dict[kw] = {
                "top": self._ranked_keywords(req_json, 0),
                "rising": self._ranked_keywords(req_json, 1),
            }
        return result_dict

    def related_queries(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
        """Per-keyword related queries: ``top`` / ``rising`` lists of ranked-keyword dicts."""
        result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
        for request_json in self.related_queries_widget_list:
            try:
                kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
            except KeyError:
                kw = ""
            related_payload = {
                "req": json.dumps(request_json["request"]),
                "token": request_json["token"],
                "tz": self.tz,
            }
            req_json = self._get_data(
                url=ep.RELATED_QUERIES,
                method="get",
                trim_chars=5,
                params=related_payload,
            )
            top_list = self._ranked_keywords(req_json, 0)
            rising_list = self._ranked_keywords(req_json, 1)
            result_dict[kw] = {"top": top_list, "rising": rising_list}
        return result_dict

    def trending_searches(self, geo: str = "Worldwide", window: int = 8) -> list[list[Any]]:
        """
        Trending searches for ``geo``, via the ``batchexecute`` RPC.

        ``geo`` is ``"Worldwide"`` or a country code such as ``"US"``. Returns raw
        ``[term, growth_percent, volume_index]`` rows.
        """
        return self._rpc.trending_searches(geo, window)

    def geo_list(self) -> Any:
        """The full geo hierarchy Google's own region picker is built from."""
        return self._rpc.geo_list()

    def suggestions(self, query: str) -> list[list[Any]]:
        """Entity suggestions for a partial query."""
        return self._rpc.suggestions(query)

    @property
    def rpc_client(self) -> BatchExecuteClient:
        """The RPC client, for building a trending provider around."""
        return self._rpc

    @property
    def rss_client(self) -> TrendingRssClient:
        """The RSS client, for building a trending provider around."""
        return self._rss

    def today_searches(self, pn: str = "US") -> list[str]:
        """Today's search titles for ``pn`` (country code)."""
        forms = {"ns": 15, "geo": pn, "tz": "-180", "hl": self.hl}
        req_json = self._get_data(
            url=ep.TODAY_SEARCHES,
            method="get",
            trim_chars=5,
            params=forms,
            **self.requests_args,
        )["default"]["trendingSearchesDays"][0]["trendingSearches"]
        return [str(trend["title"]) for trend in req_json]

    def realtime_trending_searches(
        self,
        pn: str = "US",
        cat: str = "all",
        count: int = 300,
    ) -> list[dict[str, Any]]:
        ri_value = min(300, count)
        rs_value = min(200, count - 1) if count < 200 else 200
        forms = {
            "ns": 15,
            "geo": pn,
            "tz": "300",
            "hl": self.hl,
            "cat": cat,
            "fi": "0",
            "fs": "0",
            "ri": ri_value,
            "rs": rs_value,
            "sort": 0,
        }
        req_json = self._get_data(
            url=ep.REALTIME_TRENDING,
            method="get",
            trim_chars=5,
            params=forms,
        )["storySummaries"]["trendingStories"]
        wanted_keys = ("entityNames", "title")
        return [{k: ts[k] for k in ts if k in wanted_keys} for ts in req_json]

    def top_charts(
        self,
        date: int | str,
        hl: str = "en-US",
        tz: int = 300,
        geo: str = "GLOBAL",
    ) -> list[dict[str, Any]] | None:
        try:
            year = int(date)
        except (TypeError, ValueError) as e:
            raise ValueError("The date must be a year with format YYYY.") from e
        chart_payload = {"hl": hl, "tz": tz, "date": year, "geo": geo, "isMobile": False}
        req_json = self._get_data(
            url=ep.TOP_CHARTS,
            method="get",
            trim_chars=5,
            params=chart_payload,
        )
        try:
            return list(req_json["topCharts"][0]["listItems"])
        except IndexError:
            return None

    def autocomplete(self, keyword: str) -> Any:
        """
        Entity suggestions from the legacy ``api/autocomplete`` endpoint.

        Superseded by :meth:`suggestions`, which uses the RPC the current UI calls and
        returns better matches -- ``"tech"`` yields *Technology* there but *Technics*,
        *Technivorm*, *TechnoMarine* here. Kept because the endpoint still answers.
        """
        kw_param = quote(keyword)
        parameters = {"hl": self.hl}
        return self._get_data(
            url=ep.AUTOCOMPLETE_PREFIX + kw_param,
            params=parameters,
            method="get",
            trim_chars=5,
        )["default"]["topics"]

    def geo_picker(self) -> Any:
        return self._get_data(
            url=ep.GEO_PICKER,
            params={"hl": self.hl, "tz": self.tz},
            method="get",
            trim_chars=5,
        )

    def categories(self) -> Any:
        return self._get_data(
            url=ep.CATEGORY_PICKER,
            params={"hl": self.hl, "tz": self.tz},
            method="get",
            trim_chars=5,
        )

The RPC client, for building a trending provider around.

The RSS client, for building a trending provider around.

Entity suggestions from the legacy api/autocomplete endpoint.

Superseded by :meth:suggestions, which uses the RPC the current UI calls and returns better matches -- "tech" yields Technology there but Technics, Technivorm, TechnoMarine here. Kept because the endpoint still answers.

Source code in src/trendflow/_trends_http/session.py
def autocomplete(self, keyword: str) -> Any:
    """
    Entity suggestions from the legacy ``api/autocomplete`` endpoint.

    Superseded by :meth:`suggestions`, which uses the RPC the current UI calls and
    returns better matches -- ``"tech"`` yields *Technology* there but *Technics*,
    *Technivorm*, *TechnoMarine* here. Kept because the endpoint still answers.
    """
    kw_param = quote(keyword)
    parameters = {"hl": self.hl}
    return self._get_data(
        url=ep.AUTOCOMPLETE_PREFIX + kw_param,
        params=parameters,
        method="get",
        trim_chars=5,
    )["default"]["topics"]

The full geo hierarchy Google's own region picker is built from.

Source code in src/trendflow/_trends_http/session.py
def geo_list(self) -> Any:
    """The full geo hierarchy Google's own region picker is built from."""
    return self._rpc.geo_list()

Return the raw default object from the interest-by-region response.

Source code in src/trendflow/_trends_http/session.py
def interest_by_region(
    self,
    resolution: str = "COUNTRY",
    inc_low_vol: bool = False,
    inc_geo_code: bool = False,
) -> dict[str, Any]:
    """Return the raw ``default`` object from the interest-by-region response."""
    g = _primary_geo(self.geo)
    if g == "":
        self.interest_by_region_widget["request"]["resolution"] = resolution
    elif g == "US" and resolution in ("DMA", "CITY", "REGION"):
        self.interest_by_region_widget["request"]["resolution"] = resolution

    self.interest_by_region_widget["request"]["includeLowSearchVolumeGeos"] = inc_low_vol

    region_payload = {
        "req": json.dumps(self.interest_by_region_widget["request"]),
        "token": self.interest_by_region_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.INTEREST_BY_REGION,
        method="get",
        trim_chars=5,
        params=region_payload,
    )
    default = req_json["default"]
    if inc_geo_code:
        if "geoMapData" in default and default["geoMapData"]:
            first = default["geoMapData"][0]
            if "geoCode" not in first and "coordinates" not in first:
                logger.warning("Could not find geo_code column; skipping")
    return default

Return the raw default object from the interest-over-time widget response.

Source code in src/trendflow/_trends_http/session.py
def interest_over_time(self) -> dict[str, Any]:
    """Return the raw ``default`` object from the interest-over-time widget response."""
    over_time_payload = {
        "req": json.dumps(self.interest_over_time_widget["request"]),
        "token": self.interest_over_time_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.INTEREST_OVER_TIME,
        method="get",
        trim_chars=5,
        params=over_time_payload,
    )
    return req_json["default"]

Return the raw default object from the multirange interest-over-time response.

Source code in src/trendflow/_trends_http/session.py
def multirange_interest_over_time(self) -> dict[str, Any]:
    """Return the raw ``default`` object from the multirange interest-over-time response."""
    over_time_payload = {
        "req": json.dumps(self.interest_over_time_widget["request"]),
        "token": self.interest_over_time_widget["token"],
        "tz": self.tz,
    }
    req_json = self._get_data(
        url=ep.MULTIRANGE_INTEREST_OVER_TIME,
        method="get",
        trim_chars=5,
        params=over_time_payload,
    )
    return req_json["default"]

Per-keyword related queries: top / rising lists of ranked-keyword dicts.

Source code in src/trendflow/_trends_http/session.py
def related_queries(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
    """Per-keyword related queries: ``top`` / ``rising`` lists of ranked-keyword dicts."""
    result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
    for request_json in self.related_queries_widget_list:
        try:
            kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
        except KeyError:
            kw = ""
        related_payload = {
            "req": json.dumps(request_json["request"]),
            "token": request_json["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.RELATED_QUERIES,
            method="get",
            trim_chars=5,
            params=related_payload,
        )
        top_list = self._ranked_keywords(req_json, 0)
        rising_list = self._ranked_keywords(req_json, 1)
        result_dict[kw] = {"top": top_list, "rising": rising_list}
    return result_dict

Per-keyword related topics: top / rising lists of ranked-keyword dicts.

Source code in src/trendflow/_trends_http/session.py
def related_topics(self) -> dict[str, dict[str, list[dict[str, Any]] | None]]:
    """Per-keyword related topics: ``top`` / ``rising`` lists of ranked-keyword dicts."""
    result_dict: dict[str, dict[str, list[dict[str, Any]] | None]] = {}
    for request_json in self.related_topics_widget_list:
        try:
            kw = request_json["request"]["restriction"]["complexKeywordsRestriction"]["keyword"][0]["value"]
        except KeyError:
            kw = ""
        related_payload = {
            "req": json.dumps(request_json["request"]),
            "token": request_json["token"],
            "tz": self.tz,
        }
        req_json = self._get_data(
            url=ep.RELATED_QUERIES,
            method="get",
            trim_chars=5,
            params=related_payload,
        )
        result_dict[kw] = {
            "top": self._ranked_keywords(req_json, 0),
            "rising": self._ranked_keywords(req_json, 1),
        }
    return result_dict

Drop the cookie jar; the cached widget tokens are re-fetched by build_payload.

Source code in src/trendflow/_trends_http/session.py
def reset_cookies(self) -> None:
    """Drop the cookie jar; the cached widget tokens are re-fetched by build_payload."""
    self._http.reset_cookies()

Pin a proxy for every subsequent request and drop the old exit IP's cookie jar.

Source code in src/trendflow/_trends_http/session.py
def set_proxy(self, proxy_url: str) -> None:
    """Pin a proxy for every subsequent request and drop the old exit IP's cookie jar."""
    self.proxies = [proxy_url]
    self._http.set_proxy(proxy_url)
    self._rpc.proxy = proxy_url
    self._rss.proxy = proxy_url

Entity suggestions for a partial query.

Source code in src/trendflow/_trends_http/session.py
def suggestions(self, query: str) -> list[list[Any]]:
    """Entity suggestions for a partial query."""
    return self._rpc.suggestions(query)

Today's search titles for pn (country code).

Source code in src/trendflow/_trends_http/session.py
def today_searches(self, pn: str = "US") -> list[str]:
    """Today's search titles for ``pn`` (country code)."""
    forms = {"ns": 15, "geo": pn, "tz": "-180", "hl": self.hl}
    req_json = self._get_data(
        url=ep.TODAY_SEARCHES,
        method="get",
        trim_chars=5,
        params=forms,
        **self.requests_args,
    )["default"]["trendingSearchesDays"][0]["trendingSearches"]
    return [str(trend["title"]) for trend in req_json]

Trending searches for geo, via the batchexecute RPC.

geo is "Worldwide" or a country code such as "US". Returns raw [term, growth_percent, volume_index] rows.

Source code in src/trendflow/_trends_http/session.py
def trending_searches(self, geo: str = "Worldwide", window: int = 8) -> list[list[Any]]:
    """
    Trending searches for ``geo``, via the ``batchexecute`` RPC.

    ``geo`` is ``"Worldwide"`` or a country code such as ``"US"``. Returns raw
    ``[term, growth_percent, volume_index]`` rows.
    """
    return self._rpc.trending_searches(geo, window)

Bases: Exception

The Trends endpoint returned a non-JSON or error response.

Source code in src/trendflow/_trends_http/exceptions.py
class ResponseError(Exception):
    """The Trends endpoint returned a non-JSON or error response."""

    def __init__(self, message: str, response: httpx.Response) -> None:
        super().__init__(message)
        self.response = response

    @classmethod
    def from_response(cls, response: httpx.Response) -> Self:
        message = f"The request failed: Google returned a response with code {response.status_code}"
        return cls(message, response)

Bases: ResponseError

HTTP 429 from Google Trends.

Source code in src/trendflow/_trends_http/exceptions.py
class TooManyRequestsError(ResponseError):
    """HTTP 429 from Google Trends."""

    @classmethod
    def from_response(cls, response: httpx.Response) -> Self:
        message = (
            f"The request failed: Google returned a response with code {response.status_code}. "
            f"Google rate-limits by exit IP; see {RATE_LIMIT_DOCS_URL} for how to work around it."
        )
        return cls(message, response)