gitlytics 0.1.6__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlytics-0.1.6 → gitlytics-0.2.0}/PKG-INFO +2 -2
- {gitlytics-0.1.6 → gitlytics-0.2.0}/README.md +1 -1
- {gitlytics-0.1.6 → gitlytics-0.2.0}/pyproject.toml +1 -1
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/__init__.py +17 -2
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/api.py +40 -15
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/automation.py +4 -2
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/core.py +31 -8
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/process.py +20 -15
- gitlytics-0.2.0/src/gitlytics/static/assets/html2canvas-pro.esm-9xys3ejh.js +6 -0
- gitlytics-0.2.0/src/gitlytics/static/assets/html2canvas.esm-DXEQVQnt.js +5 -0
- gitlytics-0.2.0/src/gitlytics/static/assets/index-Cx6oOScf.js +87 -0
- gitlytics-0.2.0/src/gitlytics/static/assets/index-DxtMptVs.css +1 -0
- gitlytics-0.1.6/src/gitlytics/static/assets/index.es-BM2mGRzK.js → gitlytics-0.2.0/src/gitlytics/static/assets/index.es-DZq7ceO3.js +2 -15
- gitlytics-0.2.0/src/gitlytics/static/assets/jspdf.es.min-CaU6ZJCD.js +79 -0
- gitlytics-0.1.6/src/gitlytics/static/assets/purify.es-Csrj9YNg.js → gitlytics-0.2.0/src/gitlytics/static/assets/purify.es-CC4Brkr_.js +1 -1
- gitlytics-0.2.0/src/gitlytics/static/favicon-48x48.png +0 -0
- gitlytics-0.2.0/src/gitlytics/static/google-search-icon-48x48.png +0 -0
- gitlytics-0.2.0/src/gitlytics/static/google-search-icon.png +0 -0
- gitlytics-0.2.0/src/gitlytics/static/index.html +24 -0
- gitlytics-0.2.0/src/gitlytics/static/robots.txt +5 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/sitemap.xml +5 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/PKG-INFO +2 -2
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/SOURCES.txt +10 -8
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_api.py +80 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_automation.py +23 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_process.py +31 -0
- gitlytics-0.1.6/src/gitlytics/static/assets/html2canvas-pro.esm-C9_j7xg5.js +0 -10
- gitlytics-0.1.6/src/gitlytics/static/assets/html2canvas.esm-QH1iLAAe.js +0 -22
- gitlytics-0.1.6/src/gitlytics/static/assets/index-D6vJCUrl.js +0 -504
- gitlytics-0.1.6/src/gitlytics/static/assets/index-hl2LPOqz.css +0 -1
- gitlytics-0.1.6/src/gitlytics/static/assets/jspdf.es.min-cihQsb1K.js +0 -170
- gitlytics-0.1.6/src/gitlytics/static/index.html +0 -19
- gitlytics-0.1.6/src/gitlytics/static/octocat.png +0 -0
- gitlytics-0.1.6/src/gitlytics/static/robots.txt +0 -5
- {gitlytics-0.1.6 → gitlytics-0.2.0}/LICENSE +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/setup.cfg +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/__main__.py +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/cli.py +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/android-chrome-192x192.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/android-chrome-512x512.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/apple-touch-icon.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon-16x16.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon-32x32.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/favicon.ico +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics/static/gitlytics-logo.png +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/dependency_links.txt +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/entry_points.txt +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/requires.txt +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/src/gitlytics.egg-info/top_level.txt +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_cli.py +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_core.py +0 -0
- {gitlytics-0.1.6 → gitlytics-0.2.0}/tests/test_username.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlytics
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Monitor and automate your GitHub repository traffic analytics.
|
|
5
5
|
Author-email: Ameya Chopade <ameyaccod171@gmail.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -96,7 +96,7 @@ Dynamic: license-file
|
|
|
96
96
|
|
|
97
97
|
The full Gitlytics ecosystem spans across a few repositories. If you are looking for the live web dashboard or the automation cron job, check out the links below:
|
|
98
98
|
|
|
99
|
-
- **[Gitlytics
|
|
99
|
+
- 📊 **[Gitlytics Live Dashboard](https://dashboard.gitlytics.dev)**: The production web interface to visualize repository traffic analytics, trends, and historical charts.
|
|
100
100
|
- ⚙️ **[Gitlytics Automation](https://github.com/ameyac11/gitlytics-github-traffic-automation)**: The GitHub Action companion tool that automates fetching and saving to defeat GitHub's 14-day traffic limit.
|
|
101
101
|
|
|
102
102
|
---
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
|
|
65
65
|
The full Gitlytics ecosystem spans across a few repositories. If you are looking for the live web dashboard or the automation cron job, check out the links below:
|
|
66
66
|
|
|
67
|
-
- **[Gitlytics
|
|
67
|
+
- 📊 **[Gitlytics Live Dashboard](https://dashboard.gitlytics.dev)**: The production web interface to visualize repository traffic analytics, trends, and historical charts.
|
|
68
68
|
- ⚙️ **[Gitlytics Automation](https://github.com/ameyac11/gitlytics-github-traffic-automation)**: The GitHub Action companion tool that automates fetching and saving to defeat GitHub's 14-day traffic limit.
|
|
69
69
|
|
|
70
70
|
---
|
|
@@ -8,7 +8,7 @@ import json
|
|
|
8
8
|
|
|
9
9
|
# Single source of truth for the package version.
|
|
10
10
|
# Mirrors the version in pyproject.toml — keep them in sync.
|
|
11
|
-
__version__ = "0.
|
|
11
|
+
__version__ = "0.2.0"
|
|
12
12
|
|
|
13
13
|
__all__ = ["fetch_traffic", "sync", "serve_dashboard", "__version__"]
|
|
14
14
|
|
|
@@ -98,6 +98,11 @@ def sync(token: str, repo_name=None, data_dir: str = "./data", output_mode: str
|
|
|
98
98
|
exported JSON — acts as a security firewall.
|
|
99
99
|
metrics: Optional list of metrics to fetch (e.g., ``["views", "clones"]``).
|
|
100
100
|
"""
|
|
101
|
+
if data_dir and not os.path.isabs(data_dir) and not os.path.exists(data_dir):
|
|
102
|
+
parent_dir = os.path.join("..", data_dir)
|
|
103
|
+
if os.path.exists(parent_dir):
|
|
104
|
+
data_dir = parent_dir
|
|
105
|
+
|
|
101
106
|
# Hand off to the automation engine — it handles deduplication and schema migration
|
|
102
107
|
run_sync(
|
|
103
108
|
token=token,
|
|
@@ -142,7 +147,17 @@ def serve_dashboard(host: str = "127.0.0.1", port: int = 8000, token: str = None
|
|
|
142
147
|
if token:
|
|
143
148
|
os.environ["GITLYTICS_TOKEN"] = token
|
|
144
149
|
if data_dir:
|
|
145
|
-
|
|
150
|
+
from pathlib import Path
|
|
151
|
+
abs_data_dir = os.path.abspath(data_dir)
|
|
152
|
+
if not os.path.exists(abs_data_dir) and not os.path.isabs(data_dir):
|
|
153
|
+
parent_dir = os.path.abspath(os.path.join("..", data_dir))
|
|
154
|
+
if os.path.exists(parent_dir):
|
|
155
|
+
abs_data_dir = parent_dir
|
|
156
|
+
if not os.path.exists(abs_data_dir):
|
|
157
|
+
print(f"⚠️ Warning: The specified data directory '{data_dir}' (resolved to '{abs_data_dir}') does not exist.")
|
|
158
|
+
elif not any(Path(abs_data_dir).glob("traffic_*.csv")):
|
|
159
|
+
print(f"⚠️ Warning: No traffic_*.csv database files found in '{data_dir}' (resolved to '{abs_data_dir}').")
|
|
160
|
+
os.environ["GITLYTICS_DATA_DIR"] = abs_data_dir
|
|
146
161
|
uvicorn.run("gitlytics.api:app", host=host, port=port, reload=False)
|
|
147
162
|
finally:
|
|
148
163
|
if _orig_token is None:
|
|
@@ -135,22 +135,38 @@ def get_traffic(token: str = Body("", embed=True)):
|
|
|
135
135
|
if not ok:
|
|
136
136
|
raise HTTPException(status_code=401, detail="Invalid token")
|
|
137
137
|
data_dir = os.environ.get("GITLYTICS_DATA_DIR")
|
|
138
|
+
dfs = []
|
|
138
139
|
if data_dir:
|
|
139
140
|
data_dir_path = Path(data_dir)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
for f in csv_files:
|
|
143
|
-
try:
|
|
144
|
-
dfs.append(pd.read_csv(f))
|
|
145
|
-
except Exception as exc:
|
|
146
|
-
logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
|
|
147
|
-
if dfs:
|
|
148
|
-
df = pd.concat(dfs, ignore_index=True)
|
|
149
|
-
df = df.drop_duplicates(subset=["date", "repository"], keep="last")
|
|
141
|
+
if not data_dir_path.exists():
|
|
142
|
+
logger.warning(f"Data directory '{data_dir}' does not exist.")
|
|
150
143
|
else:
|
|
151
|
-
|
|
144
|
+
csv_files = list(data_dir_path.glob("traffic_*.csv"))
|
|
145
|
+
if not csv_files:
|
|
146
|
+
logger.warning(f"No traffic_*.csv files found in '{data_dir}'.")
|
|
147
|
+
for f in csv_files:
|
|
148
|
+
try:
|
|
149
|
+
dfs.append(pd.read_csv(f))
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.warning(f"Skipping unreadable CSV '{f}': {exc}")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
live_df = fetch_traffic_data(active_token)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
logger.warning(f"Failed to fetch live traffic: {exc}")
|
|
157
|
+
live_df = pd.DataFrame()
|
|
158
|
+
|
|
159
|
+
if dfs:
|
|
160
|
+
csv_df = pd.concat(dfs, ignore_index=True)
|
|
161
|
+
if not live_df.empty:
|
|
162
|
+
df = pd.concat([csv_df, live_df], ignore_index=True)
|
|
163
|
+
else:
|
|
164
|
+
df = csv_df
|
|
152
165
|
else:
|
|
153
|
-
df =
|
|
166
|
+
df = live_df
|
|
167
|
+
|
|
168
|
+
if not df.empty:
|
|
169
|
+
df = df.drop_duplicates(subset=["date", "repository"], keep="last")
|
|
154
170
|
|
|
155
171
|
df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
|
|
156
172
|
|
|
@@ -171,8 +187,17 @@ def get_traffic(token: str = Body("", embed=True)):
|
|
|
171
187
|
|
|
172
188
|
@app.post("/api/upload-csv")
|
|
173
189
|
def upload_csv(file: UploadFile = File(...)):
|
|
174
|
-
# Accept a user-uploaded CSV — deep stats not available in CSV mode
|
|
175
190
|
try:
|
|
191
|
+
data_dir = os.environ.get("GITLYTICS_DATA_DIR")
|
|
192
|
+
if data_dir:
|
|
193
|
+
data_dir_path = Path(data_dir)
|
|
194
|
+
data_dir_path.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
file.file.seek(0)
|
|
196
|
+
content = file.file.read()
|
|
197
|
+
file.file.seek(0)
|
|
198
|
+
dest = data_dir_path / f"traffic_uploaded_{int(_time.time())}.csv"
|
|
199
|
+
with open(dest, "wb") as f:
|
|
200
|
+
f.write(content)
|
|
176
201
|
df = process_uploaded_csv(file.file)
|
|
177
202
|
df = df.replace([float('inf'), float('-inf')], None).where(pd.notnull(df), None)
|
|
178
203
|
payload = build_react_payload(df, deep_stats=None)
|
|
@@ -192,7 +217,7 @@ def serve_index():
|
|
|
192
217
|
return FileResponse(index_file)
|
|
193
218
|
return JSONResponse(
|
|
194
219
|
status_code=503,
|
|
195
|
-
content={"error": "Dashboard not found
|
|
220
|
+
content={"error": "Dashboard assets not found in the package installation."}
|
|
196
221
|
)
|
|
197
222
|
|
|
198
223
|
|
|
@@ -209,7 +234,7 @@ def serve_spa_fallback(full_path: str):
|
|
|
209
234
|
|
|
210
235
|
return JSONResponse(
|
|
211
236
|
status_code=503,
|
|
212
|
-
content={"error": "Dashboard not found
|
|
237
|
+
content={"error": "Dashboard assets not found in the package installation."}
|
|
213
238
|
)
|
|
214
239
|
|
|
215
240
|
|
|
@@ -130,13 +130,15 @@ def run_sync_cycle(token: str, repo_names=None, data_dir="./data", output_mode="
|
|
|
130
130
|
else:
|
|
131
131
|
existing_fields = new_fields
|
|
132
132
|
|
|
133
|
-
#
|
|
133
|
+
# Merge fresh data into existing rows — preserves columns not present in this sync run
|
|
134
134
|
new_records_added = 0
|
|
135
135
|
for _, row in df.iterrows():
|
|
136
136
|
key = (str(row["repository"]), str(row["date"]))
|
|
137
137
|
if key not in existing_data:
|
|
138
138
|
new_records_added += 1
|
|
139
|
-
|
|
139
|
+
existing_data[key] = row.to_dict()
|
|
140
|
+
else:
|
|
141
|
+
existing_data[key].update(row.to_dict())
|
|
140
142
|
|
|
141
143
|
# Sort all rows by date and repo name before writing back to disk
|
|
142
144
|
final_rows = []
|
|
@@ -221,8 +221,8 @@ def get_deep_repo_stats(token: str, full_name: str) -> dict:
|
|
|
221
221
|
"has_code_of_conduct": None,
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
# Commit activity — GitHub computes this async and returns 202 when not ready
|
|
225
|
-
#
|
|
224
|
+
# Commit activity — GitHub computes this async and returns 202 when not ready.
|
|
225
|
+
# total_commits here is the trailing 52-week (12-month) sum, NOT lifetime.
|
|
226
226
|
ca_url = f"{BASE}/repos/{full_name}/stats/commit_activity"
|
|
227
227
|
ca_data, status = _safe_get(ca_url, h)
|
|
228
228
|
if status == 202:
|
|
@@ -254,12 +254,35 @@ def get_deep_repo_stats(token: str, full_name: str) -> dict:
|
|
|
254
254
|
stats["has_contributing"] = bool(files.get("contributing"))
|
|
255
255
|
stats["has_code_of_conduct"] = bool(files.get("code_of_conduct"))
|
|
256
256
|
|
|
257
|
-
# Releases — count
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
257
|
+
# Releases — count real total using Link header pagination, then get latest date
|
|
258
|
+
try:
|
|
259
|
+
rel_resp = requests.get(
|
|
260
|
+
f"{BASE}/repos/{full_name}/releases",
|
|
261
|
+
headers=h,
|
|
262
|
+
params={"per_page": 1},
|
|
263
|
+
timeout=10,
|
|
264
|
+
)
|
|
265
|
+
if rel_resp.status_code == 200:
|
|
266
|
+
link = rel_resp.headers.get("Link", "")
|
|
267
|
+
if 'rel="last"' in link:
|
|
268
|
+
import re as _re
|
|
269
|
+
m = _re.search(r'page=(\d+)>; rel="last"', link)
|
|
270
|
+
stats["total_releases"] = int(m.group(1)) if m else 1
|
|
271
|
+
else:
|
|
272
|
+
# Only one page — count items in this page
|
|
273
|
+
items = rel_resp.json()
|
|
274
|
+
stats["total_releases"] = len(items) if isinstance(items, list) else 0
|
|
275
|
+
# Fetch the latest release separately for its date
|
|
276
|
+
latest_resp = requests.get(
|
|
277
|
+
f"{BASE}/repos/{full_name}/releases/latest",
|
|
278
|
+
headers=h,
|
|
279
|
+
timeout=10,
|
|
280
|
+
)
|
|
281
|
+
if latest_resp.status_code == 200:
|
|
282
|
+
latest = latest_resp.json()
|
|
283
|
+
stats["last_release_at"] = latest.get("published_at") or latest.get("created_at")
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
logger.warning(f"Could not fetch releases for {full_name}: {exc}")
|
|
263
286
|
|
|
264
287
|
return stats
|
|
265
288
|
|
|
@@ -35,12 +35,12 @@ def build_json_payload(df: pd.DataFrame, return_format: str = "timeseries", expo
|
|
|
35
35
|
for repo, group in df.groupby("repository"):
|
|
36
36
|
group = group.sort_values("date")
|
|
37
37
|
|
|
38
|
-
r_views =
|
|
39
|
-
r_clones =
|
|
40
|
-
r_unique_v =
|
|
41
|
-
r_unique_c =
|
|
42
|
-
r_stars =
|
|
43
|
-
r_forks =
|
|
38
|
+
r_views = _safe_int(group["views"].sum()) if "views" in group.columns else 0
|
|
39
|
+
r_clones = _safe_int(group["clones"].sum()) if "clones" in group.columns else 0
|
|
40
|
+
r_unique_v = _safe_int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
|
|
41
|
+
r_unique_c = _safe_int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
|
|
42
|
+
r_stars = _safe_int(group["stars"].dropna().iloc[-1]) if "stars" in group.columns and not group["stars"].dropna().empty else 0
|
|
43
|
+
r_forks = _safe_int(group["forks"].dropna().iloc[-1]) if "forks" in group.columns and not group["forks"].dropna().empty else 0
|
|
44
44
|
r_is_private = bool(group["is_private"].dropna().iloc[-1]) if "is_private" in group.columns and not group["is_private"].dropna().empty else False
|
|
45
45
|
|
|
46
46
|
top_ref = str(group["top_referrer"].dropna().iloc[-1]) if "top_referrer" in group.columns and not group["top_referrer"].dropna().empty else ""
|
|
@@ -103,6 +103,11 @@ def build_json_payload(df: pd.DataFrame, return_format: str = "timeseries", expo
|
|
|
103
103
|
def process_uploaded_csv(uploaded_file) -> pd.DataFrame:
|
|
104
104
|
"""Reads a user-uploaded CSV and normalises column names to match our tidy schema."""
|
|
105
105
|
raw_df = pd.read_csv(uploaded_file)
|
|
106
|
+
|
|
107
|
+
# Normalise capitalized "Date" column before any other checks
|
|
108
|
+
if "Date" in raw_df.columns and "date" not in raw_df.columns:
|
|
109
|
+
raw_df = raw_df.rename(columns={"Date": "date"})
|
|
110
|
+
|
|
106
111
|
if "repository" not in raw_df.columns:
|
|
107
112
|
if "repo_name" in raw_df.columns:
|
|
108
113
|
raw_df = raw_df.rename(columns={"repo_name": "repository"})
|
|
@@ -113,7 +118,7 @@ def process_uploaded_csv(uploaded_file) -> pd.DataFrame:
|
|
|
113
118
|
})
|
|
114
119
|
else:
|
|
115
120
|
raise ValueError("Invalid CSV format: missing 'repository' column")
|
|
116
|
-
|
|
121
|
+
|
|
117
122
|
if "date" not in raw_df.columns:
|
|
118
123
|
raise ValueError("Invalid CSV format: missing required 'date' column")
|
|
119
124
|
return raw_df
|
|
@@ -161,10 +166,10 @@ def build_react_payload(df: pd.DataFrame, deep_stats: dict = None) -> list:
|
|
|
161
166
|
for repo, group in df.groupby("repository"):
|
|
162
167
|
group = group.sort_values("date")
|
|
163
168
|
|
|
164
|
-
r_views =
|
|
165
|
-
r_clones =
|
|
166
|
-
r_unique_v =
|
|
167
|
-
r_unique_c =
|
|
169
|
+
r_views = _safe_int(group["views"].sum()) if "views" in group.columns else 0
|
|
170
|
+
r_clones = _safe_int(group["clones"].sum()) if "clones" in group.columns else 0
|
|
171
|
+
r_unique_v = _safe_int(group["unique_visitors"].sum()) if "unique_visitors" in group.columns else 0
|
|
172
|
+
r_unique_c = _safe_int(group["unique_cloners"].sum()) if "unique_cloners" in group.columns else 0
|
|
168
173
|
r_stars = _safe_int(group["stars"].dropna().iloc[-1]) if "stars" in group.columns and not group["stars"].dropna().empty else 0
|
|
169
174
|
r_forks = _safe_int(group["forks"].dropna().iloc[-1]) if "forks" in group.columns and not group["forks"].dropna().empty else 0
|
|
170
175
|
r_is_private = bool(group["is_private"].dropna().iloc[-1]) if "is_private" in group.columns and not group["is_private"].dropna().empty else False
|
|
@@ -194,13 +199,13 @@ def build_react_payload(df: pd.DataFrame, deep_stats: dict = None) -> list:
|
|
|
194
199
|
date_str = str(row["date"])
|
|
195
200
|
daily_views.append({
|
|
196
201
|
"timestamp": date_str,
|
|
197
|
-
"count":
|
|
198
|
-
"uniques":
|
|
202
|
+
"count": _safe_int(row.get("views", 0)),
|
|
203
|
+
"uniques": _safe_int(row.get("unique_visitors", 0))
|
|
199
204
|
})
|
|
200
205
|
daily_clones.append({
|
|
201
206
|
"timestamp": date_str,
|
|
202
|
-
"count":
|
|
203
|
-
"uniques":
|
|
207
|
+
"count": _safe_int(row.get("clones", 0)),
|
|
208
|
+
"uniques": _safe_int(row.get("unique_cloners", 0)) # fixed typo
|
|
204
209
|
})
|
|
205
210
|
|
|
206
211
|
raw_refs_val = group["_raw_referrers"].iloc[-1] if "_raw_referrers" in group.columns else None
|