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