77import builtins
88import csv
99import io
10+ import os
1011import sys
1112import time
1213from datetime import date
@@ -171,6 +172,12 @@ def _request(
171172 """
172173 url = self ._build_url (path )
173174 headers = self ._get_headers ()
175+
176+ # Show request info if ASA_DEBUG is set
177+ if os .environ .get ("ASA_DEBUG" ):
178+ short_url = url .replace ("https://api.searchads.apple.com/api/v5/" , "" )
179+ print (f"[custom-reports] { method } { short_url } " , file = sys .stderr )
180+
174181 logger .debug ("%s %s" , method , url )
175182
176183 last_exception : AppleSearchAdsError | None = None
@@ -284,40 +291,103 @@ async def _request_async(
284291 raise last_exception
285292 raise NetworkError ("Request failed after all retries" )
286293
287- def list_reports (
288- self , limit : int = 100 , offset : int = 0
289- ) -> "builtins.list[ImpressionShareReport]" :
294+ def list_reports (self ) -> "builtins.list[ImpressionShareReport]" :
290295 """List all impression share reports.
291296
292- Args:
293- limit: Maximum number of reports to return.
294- offset: Number of reports to skip.
295-
296297 Returns:
297298 List of impression share reports.
298299 """
299- params = { "limit" : limit , "offset" : offset }
300- data = self ._request ("GET" , "" , params = params )
300+ # Apple's custom-reports endpoint doesn't support pagination params
301+ data = self ._request ("GET" , "" )
301302
302303 reports : builtins .list [ImpressionShareReport ] = []
303304 for item in data .get ("data" , []):
304305 reports .append (ImpressionShareReport .model_validate (item ))
305306 return reports
306307
307- async def list_reports_async (
308- self , limit : int = 100 , offset : int = 0
309- ) -> "builtins.list[ImpressionShareReport]" :
310- """List all impression share reports asynchronously.
308+ def find_existing_report (
309+ self ,
310+ start_date : date ,
311+ end_date : date ,
312+ granularity : GranularityType = GranularityType .DAILY ,
313+ ) -> ImpressionShareReport | None :
314+ """Find an existing completed report covering the requested date range.
315+
316+ Apple limits impression share reports to 10 per day. This method
317+ helps avoid creating duplicate reports by finding existing ones.
318+
319+ A report is reusable if:
320+ 1. Exact match: covers the full requested range, OR
321+ 2. Close match: starts on/before our start AND ends within 1 day of our end
322+ (useful when today just rolled over but data isn't available yet)
311323
312324 Args:
313- limit: Maximum number of reports to return.
314- offset: Number of reports to skip.
325+ start_date: Report start date.
326+ end_date: Report end date.
327+ granularity: DAILY or WEEKLY.
328+
329+ Returns:
330+ Best matching report if found, None otherwise.
331+ """
332+ try :
333+ reports = self .list_reports ()
334+
335+ # Find reports that cover our date range (with 1-day tolerance on end)
336+ candidates : builtins .list [tuple [ImpressionShareReport , int ]] = []
337+ for report in reports :
338+ if report .state != "COMPLETED" :
339+ continue
340+ if report .granularity != granularity .value :
341+ continue
342+
343+ # Parse report dates
344+ if not report .start_time or not report .end_time :
345+ continue
346+ try :
347+ report_start = date .fromisoformat (report .start_time )
348+ report_end = date .fromisoformat (report .end_time )
349+ except (ValueError , TypeError ):
350+ continue
351+
352+ # Check start date coverage
353+ if report_start > start_date :
354+ continue
355+
356+ # Check end date coverage (allow 1-day tolerance for day rollover)
357+ days_short = (end_date - report_end ).days
358+ if days_short > 1 : # More than 1 day short, skip
359+ continue
360+
361+ # Score: prefer exact matches (0 days short) over close matches
362+ candidates .append ((report , days_short ))
363+
364+ if not candidates :
365+ return None
366+
367+ # Sort by: 1) days short (prefer 0), 2) range size (prefer smaller)
368+ def sort_key (
369+ item : tuple [ImpressionShareReport , int ],
370+ ) -> tuple [int , int ]:
371+ report , days_short = item
372+ # These are guaranteed non-None since we filtered above
373+ r_start = date .fromisoformat (report .start_time ) # type: ignore[arg-type]
374+ r_end = date .fromisoformat (report .end_time ) # type: ignore[arg-type]
375+ range_size = (r_end - r_start ).days
376+ return (days_short , range_size )
377+
378+ best = min (candidates , key = sort_key )
379+ return best [0 ]
380+ except Exception :
381+ return None
382+
383+ async def list_reports_async (self ) -> "builtins.list[ImpressionShareReport]" :
384+ """List all impression share reports asynchronously.
315385
316386 Returns:
317387 List of impression share reports.
318388 """
319- params = { "limit" : limit , "offset" : offset }
320- data = await self ._request_async ("GET" , "" , params = params )
389+ # Apple's custom-reports endpoint doesn't support pagination params
390+ data = await self ._request_async ("GET" , "" )
321391
322392 reports : builtins .list [ImpressionShareReport ] = []
323393 for item in data .get ("data" , []):
@@ -635,12 +705,17 @@ def get_impression_share(
635705 country_codes : builtins .list [str ] | None = None ,
636706 poll_interval : float = 2.0 ,
637707 timeout : float = 300.0 ,
708+ reuse_existing : bool = True ,
638709 ) -> ImpressionShareReport :
639710 """Create an impression share report and wait for results.
640711
641712 This is a convenience method that creates a report and polls
642713 until it's complete.
643714
715+ NOTE: Apple limits impression share reports to 10 per day. By default,
716+ this method will check for an existing report with matching date range
717+ and reuse it to conserve your daily quota.
718+
644719 Args:
645720 start_date: Report start date (max 12 weeks ago).
646721 end_date: Report end date.
@@ -650,6 +725,7 @@ def get_impression_share(
650725 country_codes: Optional list of country codes to filter by.
651726 poll_interval: Seconds between status checks.
652727 timeout: Maximum seconds to wait.
728+ reuse_existing: If True, reuse existing report with same date range.
653729
654730 Returns:
655731 The completed report with impression share data.
@@ -669,6 +745,21 @@ def get_impression_share(
669745 share = f"{row.low_impression_share}-{row.high_impression_share}%"
670746 print(f"{keyword}: {share}")
671747 """
748+ # Check for existing report to avoid hitting daily limit
749+ if reuse_existing and not country_codes :
750+ existing = self .find_existing_report (start_date , end_date , granularity )
751+ if existing :
752+ logger .info (f"Reusing existing report { existing .id } " )
753+ if os .environ .get ("ASA_DEBUG" ):
754+ print (
755+ f"[custom-reports] Reusing existing report { existing .id } " ,
756+ file = sys .stderr ,
757+ )
758+ # Download data if needed
759+ if existing .download_uri and not existing .row :
760+ existing .row = self ._download_csv (existing .download_uri )
761+ return existing
762+
672763 report = self .create_impression_share (
673764 start_date = start_date ,
674765 end_date = end_date ,
0 commit comments