|
| 1 | +import dlt |
| 2 | +import requests |
| 3 | +import typer |
| 4 | + |
| 5 | +from .settings import log |
| 6 | + |
| 7 | +app = typer.Typer(help="Export COAT data to parquet") |
| 8 | + |
| 9 | +PLAUSIBLE_BASE_URL = "https://plausible.io/api/v2" |
| 10 | +PLAUSIBLE_METRICS = ["visitors", "visits", "pageviews", "bounce_rate", "visit_duration"] |
| 11 | +PLAUSIBLE_SITE_ID = "data.coat.no" |
| 12 | + |
| 13 | +_BREAKDOWN_DIMENSIONS = [ |
| 14 | + ("page", "event:page"), |
| 15 | + ("source", "visit:source"), |
| 16 | + ("country", "visit:country_name"), |
| 17 | + ("device", "visit:device"), |
| 18 | + ("browser", "visit:browser"), |
| 19 | + ("os", "visit:os"), |
| 20 | +] |
| 21 | + |
| 22 | + |
| 23 | +def _plausible_paginate(api_key: str, base_payload: dict, page_size: int = 10_000): |
| 24 | + """Yield all result rows from a Plausible v2 query, handling pagination.""" |
| 25 | + offset = 0 |
| 26 | + while True: |
| 27 | + payload = { |
| 28 | + **base_payload, |
| 29 | + "include": {"total_rows": True}, |
| 30 | + "pagination": {"limit": page_size, "offset": offset}, |
| 31 | + } |
| 32 | + response = requests.post( |
| 33 | + f"{PLAUSIBLE_BASE_URL}/query", |
| 34 | + headers={ |
| 35 | + "Authorization": f"Bearer {api_key}", |
| 36 | + "Content-Type": "application/json", |
| 37 | + }, |
| 38 | + json=payload, |
| 39 | + timeout=60, |
| 40 | + ) |
| 41 | + response.raise_for_status() |
| 42 | + data = response.json() |
| 43 | + results = data.get("results", []) |
| 44 | + if not results: |
| 45 | + break |
| 46 | + yield from results |
| 47 | + total_rows = data.get("meta", {}).get("total_rows", 0) |
| 48 | + offset += len(results) |
| 49 | + if offset >= total_rows: |
| 50 | + break |
| 51 | + |
| 52 | + |
| 53 | +@dlt.resource( |
| 54 | + name="plausible_timeseries", |
| 55 | + primary_key="date", |
| 56 | + write_disposition="replace", |
| 57 | +) |
| 58 | +def plausible_timeseries(api_key: str, site_id: str = PLAUSIBLE_SITE_ID): |
| 59 | + """Fetch daily timeseries stats from Plausible Analytics.""" |
| 60 | + log.info(f"Fetching Plausible timeseries for {site_id}") |
| 61 | + for row in _plausible_paginate( |
| 62 | + api_key, |
| 63 | + { |
| 64 | + "site_id": site_id, |
| 65 | + "metrics": PLAUSIBLE_METRICS, |
| 66 | + "date_range": "all", |
| 67 | + "dimensions": ["time:day"], |
| 68 | + "order_by": [["time:day", "asc"]], |
| 69 | + }, |
| 70 | + ): |
| 71 | + keys = ["date", *PLAUSIBLE_METRICS] |
| 72 | + vals = [*row["dimensions"], *row["metrics"]] |
| 73 | + yield dict(zip(keys, vals, strict=True)) |
| 74 | + |
| 75 | + |
| 76 | +@dlt.resource( |
| 77 | + name="plausible_breakdown", |
| 78 | + primary_key=["dimension", "value"], |
| 79 | + write_disposition="replace", |
| 80 | +) |
| 81 | +def plausible_breakdown(api_key: str, site_id: str = PLAUSIBLE_SITE_ID): |
| 82 | + """Fetch breakdowns by page, source, country, device, browser, and OS.""" |
| 83 | + for dim_name, dim_key in _BREAKDOWN_DIMENSIONS: |
| 84 | + log.info(f"Fetching Plausible breakdown: {dim_name} ({dim_key})") |
| 85 | + for row in _plausible_paginate( |
| 86 | + api_key, |
| 87 | + { |
| 88 | + "site_id": site_id, |
| 89 | + "metrics": PLAUSIBLE_METRICS, |
| 90 | + "date_range": "all", |
| 91 | + "dimensions": [dim_key], |
| 92 | + }, |
| 93 | + ): |
| 94 | + yield { |
| 95 | + "dimension": dim_name, |
| 96 | + "value": row["dimensions"][0], |
| 97 | + **dict(zip(PLAUSIBLE_METRICS, row["metrics"], strict=True)), |
| 98 | + } |
| 99 | + |
| 100 | + |
| 101 | +@dlt.source(name="coat_plausible", max_table_nesting=0) |
| 102 | +def plausible_source(api_key: str, site_id: str = PLAUSIBLE_SITE_ID): |
| 103 | + """Define the Plausible Analytics data source for a COAT site.""" |
| 104 | + return ( |
| 105 | + plausible_timeseries(api_key=api_key, site_id=site_id), |
| 106 | + plausible_breakdown(api_key=api_key, site_id=site_id), |
| 107 | + ) |
| 108 | + |
| 109 | + |
| 110 | +@app.command() |
| 111 | +def get_plausible_analytics( |
| 112 | + bucket_url: str = typer.Option( |
| 113 | + default="data", |
| 114 | + envvar="COAT_BUCKET_URL", |
| 115 | + help="Destination bucket URL (local path or s3://...)", |
| 116 | + ), |
| 117 | + api_key: str = typer.Option( |
| 118 | + ..., |
| 119 | + envvar="COAT_PLAUSIBLE_API_KEY", |
| 120 | + help="Plausible Stats API key", |
| 121 | + ), |
| 122 | + site_id: str = typer.Option( |
| 123 | + default=PLAUSIBLE_SITE_ID, |
| 124 | + envvar="COAT_PLAUSIBLE_SITE_ID", |
| 125 | + help="Plausible site ID (e.g. data.coat.no)", |
| 126 | + ), |
| 127 | +): |
| 128 | + """Run the Plausible analytics pipeline for data.coat.no.""" |
| 129 | + pipeline = dlt.pipeline( |
| 130 | + pipeline_name="coat_plausible", |
| 131 | + destination=dlt.destinations.filesystem( |
| 132 | + bucket_url=bucket_url, layout="{table_name}.{ext}" |
| 133 | + ), |
| 134 | + dataset_name="coat_plausible", |
| 135 | + ) |
| 136 | + pipeline.run( |
| 137 | + plausible_source(api_key=api_key, site_id=site_id), |
| 138 | + loader_file_format="parquet", |
| 139 | + ) |
| 140 | + log.info(f"Plausible pipeline complete. Data at: {bucket_url}/coat_plausible") |
| 141 | + |
| 142 | + |
| 143 | +if __name__ == "__main__": |
| 144 | + app() |
0 commit comments