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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/analytics/api_facebook_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ def get_weekly_organic_reach_of_new_posts(self, end_week: datetime):
return 0
return result[0][1]

def get_weekly_paid_views(self, end_week: datetime):
result = self._fb_client.get_paid_views(
ApiFacebookAnalytics._get_end_week_day_start(end_week),
ApiFacebookAnalytics._get_end_week_day_end(end_week),
period=ReportPeriod.WEEK,
)
if not result:
return 0
return result[0][1]

def get_weekly_new_follower_count(self, end_week: datetime):
result = self._fb_client.get_new_follower_count(
ApiFacebookAnalytics._get_end_week_day_start(end_week),
Expand Down
6 changes: 6 additions & 0 deletions src/analytics/base_analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ def get_weekly_organic_reach_of_new_posts(self, end_week: datetime):
"""
raise NotImplementedError("")

def get_weekly_paid_views(self, end_week: datetime):
"""
Get weekly statistics on paid (ads) views of new posts
"""
raise NotImplementedError("")

def get_weekly_new_follower_count(self, end_week: datetime):
"""
Get the number of new followers for the week
Expand Down
60 changes: 51 additions & 9 deletions src/facebook/facebook_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@ def get_total_reach(
) -> List[Tuple[datetime, int]]:
"""
Get statistics on the total reach of new posts.
NOTE: 'page_posts_impressions_unique' is deprecated in v19.0.
Using 'page_impressions_unique' (Total Page Reach) as replacement.
NOTE: 'page_impressions_unique' was deprecated by Meta (June 2026).
Using 'page_total_media_view_unique' as the replacement unique-reach metric.
"""
batches = self._get_all_batches(
connection_name="insights",
metric="page_impressions_unique",
metric="page_total_media_view_unique",
period=period.value,
since=since,
until=until,
Expand All @@ -89,19 +89,53 @@ def get_organic_reach(
self, since: datetime, until: datetime, period: ReportPeriod
) -> List[Tuple[datetime, int]]:
"""
Get statistics on the organic reach of new posts.
Get statistics on organic (non-paid) views of page content.
NOTE: 'page_impressions_organic_unique' was deprecated by Meta (June 2026)
with no unique-reach replacement. Using 'page_media_view' broken down by
'is_from_ads', filtered to the organic (is_from_ads == "0") rows. This is
a view count, not a unique-people count, so it isn't directly comparable
to historical organic-reach values.
"""
batches = self._get_all_batches(
connection_name="insights",
metric="page_impressions_organic_unique",
metric="page_media_view",
period=period.value,
breakdown="is_from_ads",
since=since,
until=until,
)
organic_reach = self._get_values_from_batches(
batches=batches, since=since, until=until
organic_views = self._get_values_from_batches(
batches=batches,
since=since,
until=until,
breakdown_filter={"is_from_ads": "0"},
)
return organic_reach
return organic_views

def get_paid_views(
self, since: datetime, until: datetime, period: ReportPeriod
) -> List[Tuple[datetime, int]]:
"""
Get statistics on paid (ads) views of page content.
Uses 'page_media_view' broken down by 'is_from_ads', filtered to the
paid (is_from_ads == "1") rows. See get_organic_reach for context on
why this is a view count rather than a unique-people count.
"""
batches = self._get_all_batches(
connection_name="insights",
metric="page_media_view",
period=period.value,
breakdown="is_from_ads",
since=since,
until=until,
)
paid_views = self._get_values_from_batches(
batches=batches,
since=since,
until=until,
breakdown_filter={"is_from_ads": "1"},
)
return paid_views

def get_new_follower_count(
self, since: datetime, until: datetime, period: ReportPeriod
Expand Down Expand Up @@ -214,11 +248,19 @@ def _iterate_over_pages(

@staticmethod
def _get_values_from_batches(
batches: List[dict], since: datetime, until: datetime
batches: List[dict],
since: datetime,
until: datetime,
breakdown_filter: dict = None,
) -> List[Tuple[datetime, int]]:
value_by_date = []
for batch in batches:
for value_info in batch["values"]:
if breakdown_filter and any(
value_info.get(key) != value
for key, value in breakdown_filter.items()
):
continue
end_time = dateparser.isoparse(value_info["end_time"])
if end_time < since or end_time > until:
continue
Expand Down
3 changes: 3 additions & 0 deletions src/jobs/fb_analytics_report_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def _execute(
organic_reach=app_context.facebook_analytics.get_weekly_organic_reach_of_new_posts(
end_week_day
),
paid_views=app_context.facebook_analytics.get_weekly_paid_views(
end_week_day
),
)
]
pretty_send(paragraphs, send)
43 changes: 37 additions & 6 deletions src/jobs/tg_analytics_report_job.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import logging
from datetime import datetime, timezone
from typing import Callable, Tuple
from typing import Callable, List, Tuple

from telethon.tl.types import MessageInteractionCounters

from src.app_context import AppContext
from src.jobs.base_job import BaseJob
Expand All @@ -26,8 +28,20 @@ def _execute(
entity = app_context.tg_client.api_client.loop.run_until_complete(
app_context.tg_client.api_client.get_entity(app_context.tg_client.channel)
)
messages = app_context.tg_client.api_client.loop.run_until_complete(
app_context.tg_client.api_client.get_messages(
app_context.tg_client.channel,
ids=[
message_stats.msg_id
for message_stats in stats.recent_message_interactions
],
)
)
app_context.tg_client.api_client.disconnect()
new_posts_count = len(stats.recent_message_interactions)
post_interactions = TgAnalyticsReportJob._deduplicate_albums(
stats.recent_message_interactions, messages
)
new_posts_count = len(post_interactions)
followers_stats = TgAnalyticsReportJob._get_followers_stats(stats)
message = load(
"tg_analytics_report_job__text",
Expand All @@ -47,10 +61,7 @@ def _execute(
2,
),
recent_message_views=sum(
[
message_stats.views
for message_stats in stats.recent_message_interactions
]
[message_stats.views for message_stats in post_interactions]
),
views_per_post=int(stats.views_per_post.current),
views_per_post_delta=TgAnalyticsReportJob._format_delta(
Expand All @@ -63,6 +74,26 @@ def _execute(
)
pretty_send([message], send)

@staticmethod
def _deduplicate_albums(
message_interactions: List[MessageInteractionCounters], messages: List
) -> List[MessageInteractionCounters]:
"""Collapse album (grouped_id) messages into one entry: Telegram's stats API
reports each photo of a gallery post as a separate message with the same
view count, which otherwise inflates post count and total views N-fold."""
grouped_id_by_msg_id = {
msg.id: msg.grouped_id for msg in messages if msg is not None
}
best_by_group = {}
for message_stats in message_interactions:
group_key = (
grouped_id_by_msg_id.get(message_stats.msg_id) or message_stats.msg_id
)
current_best = best_by_group.get(group_key)
if current_best is None or message_stats.views > current_best.views:
best_by_group[group_key] = message_stats
return list(best_by_group.values())

@staticmethod
def _get_followers_stats(stats) -> Tuple[int, int]:
"""
Expand Down
35 changes: 1 addition & 34 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import logging
import os
from typing import Dict, List
from typing import List
from unittest.mock import MagicMock

import pytest
from utils.json_loader import JsonLoader

from src.config_manager import ConfigManager
from src.consts import USAGE_LOG_LEVEL
Expand All @@ -13,7 +12,6 @@
from src.sheets.sheets_client import GoogleSheetsClient
from src.strings import StringsDBClient
from src.tg.sender import TelegramSender
from src.trello.trello_client import TrelloClient

# logger.usage is added to the Logger class in bot.py at runtime; tests never
# import bot, so we register it here to avoid AttributeError in job logging.
Expand All @@ -29,7 +27,6 @@ def _usage(self, message, *args, **kws):
ROOT_TEST_DIR = os.path.abspath(os.path.dirname(__file__))
STATIC_TEST_DIR = os.path.join(ROOT_TEST_DIR, "static")
SHEETS_TEST_DIR = os.path.join(STATIC_TEST_DIR, "sheets")
TRELLO_TEST_DIR = os.path.join(STATIC_TEST_DIR, "trello")

CONFIG_PATH = os.path.join(STATIC_TEST_DIR, "config.json")
CONFIG_OVERRIDE_PATH = os.path.join(STATIC_TEST_DIR, "config_override.json")
Expand All @@ -48,36 +45,6 @@ def mock_config_jobs_manager(monkeypatch):
return config_manager


@pytest.fixture
def mock_trello(monkeypatch, mock_config_manager):
def _make_request(_, uri: str, payload={}) -> (int, Dict):
load_json = JsonLoader(TRELLO_TEST_DIR).load_json

if uri.startswith("boards"):
if uri.endswith("lists"):
return 200, load_json("lists.json")
elif uri.endswith("cards"):
return 200, load_json("cards.json")
elif uri.endswith("members"):
return 200, load_json("members.json")
elif uri.endswith("customFields"):
return 200, load_json("board_custom_fields.json")
else:
return 200, load_json("board.json")
elif uri.startswith("cards"):
if uri.endswith("customFieldItems"):
return 200, load_json("card_custom_fields.json")
if uri.endswith("actions"):
return 200, load_json("card_actions.json")
elif uri.startswith("lists"):
if uri.endswith("cards"):
return 200, load_json("cards.json")

monkeypatch.setattr(TrelloClient, "_make_request", _make_request)

return TrelloClient(trello_config=mock_config_manager.get_trello_config())


@pytest.fixture
def mock_sheets_client(monkeypatch, mock_config_manager):
def _authorize(self):
Expand Down
99 changes: 0 additions & 99 deletions tests/unit/static/trello/board.json

This file was deleted.

Loading
Loading