apptrail 0.4.0__py3-none-any.whl
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.
- apptrail/__init__.py +3 -0
- apptrail/api.py +674 -0
- apptrail/auth.py +339 -0
- apptrail/cli.py +120 -0
- apptrail/config.py +53 -0
- apptrail/db.py +424 -0
- apptrail/engines.py +414 -0
- apptrail/insights.py +217 -0
- apptrail/insights_api.py +214 -0
- apptrail/listing_history.py +368 -0
- apptrail/matching.py +113 -0
- apptrail/migrations/001_initial.sql +118 -0
- apptrail/regions.json +1295 -0
- apptrail/regions.py +18 -0
- apptrail/service.py +671 -0
- apptrail/static/app.js +2624 -0
- apptrail/static/auth.css +65 -0
- apptrail/static/auth.html +89 -0
- apptrail/static/auth.js +103 -0
- apptrail/static/competitors.js +396 -0
- apptrail/static/favicon.svg +18 -0
- apptrail/static/history-chart.js +71 -0
- apptrail/static/index.html +198 -0
- apptrail/static/insights-data.js +119 -0
- apptrail/static/insights-ui.js +559 -0
- apptrail/static/loading.js +56 -0
- apptrail/static/style.css +4337 -0
- apptrail/static/tracking.js +153 -0
- apptrail/static/vendor/chart-LICENSE.md +9 -0
- apptrail/static/vendor/chart.umd.min.js +20 -0
- apptrail/static/vendor/lucide-LICENSE.txt +43 -0
- apptrail/static/vendor/lucide-icons.js +108 -0
- apptrail/worker.py +204 -0
- apptrail-0.4.0.dist-info/METADATA +135 -0
- apptrail-0.4.0.dist-info/RECORD +38 -0
- apptrail-0.4.0.dist-info/WHEEL +4 -0
- apptrail-0.4.0.dist-info/entry_points.txt +3 -0
- apptrail-0.4.0.dist-info/licenses/LICENSE +21 -0
apptrail/__init__.py
ADDED
apptrail/api.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import tempfile
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, HTTPException, Query, Request
|
|
11
|
+
from fastapi.exceptions import RequestValidationError
|
|
12
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from filelock import FileLock, Timeout
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
16
|
+
from sqlalchemy import select, text
|
|
17
|
+
from sqlalchemy.exc import IntegrityError
|
|
18
|
+
from starlette.background import BackgroundTask
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .auth import Auth
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .db import (
|
|
24
|
+
App,
|
|
25
|
+
Competitor,
|
|
26
|
+
Database,
|
|
27
|
+
Listing,
|
|
28
|
+
ListingSnapshot,
|
|
29
|
+
ListingWatch,
|
|
30
|
+
Monitor,
|
|
31
|
+
Observation,
|
|
32
|
+
Run,
|
|
33
|
+
Setting,
|
|
34
|
+
Target,
|
|
35
|
+
now,
|
|
36
|
+
)
|
|
37
|
+
from .engines import Gateway, ProviderError, apple_language, http_url
|
|
38
|
+
from .matching import match_app
|
|
39
|
+
from .regions import REGIONS, supported_country
|
|
40
|
+
from .service import Service, record
|
|
41
|
+
from .worker import Worker
|
|
42
|
+
|
|
43
|
+
Frequency = Literal["daily", "weekly", "biweekly", "monthly"]
|
|
44
|
+
Source = Literal[
|
|
45
|
+
"apple_app_store", "google_play", "google_ai_mode", "google_ai_overview", "bing_copilot"
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Payload(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KeyInput(Payload):
|
|
54
|
+
api_key: SecretStr
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ReplaceQueryInput(Payload):
|
|
58
|
+
query: str = Field(min_length=1, max_length=500)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OnboardingInput(Payload):
|
|
62
|
+
action: Literal["skip", "dismiss", "restore"]
|
|
63
|
+
step: Literal["app", "store", "ai"] | None = None
|
|
64
|
+
|
|
65
|
+
@model_validator(mode="after")
|
|
66
|
+
def require_step(self):
|
|
67
|
+
if self.action == "skip" and self.step is None:
|
|
68
|
+
raise ValueError("Choose a setup step to skip.")
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class DiscoveryInput(Payload):
|
|
73
|
+
platform: Literal["ios", "android"]
|
|
74
|
+
query: str = Field(min_length=1, max_length=500)
|
|
75
|
+
country: str = Field(default="us", pattern=r"^[a-z]{2}$")
|
|
76
|
+
language: str = Field(default="en", pattern=r"^[a-z]{2,3}(?:-[a-z0-9]{2,3})?$")
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="after")
|
|
79
|
+
def store_options(self):
|
|
80
|
+
source = "apple_app_store" if self.platform == "ios" else "google_play"
|
|
81
|
+
if not supported_country(source, self.country):
|
|
82
|
+
raise ValueError("Choose a supported country for this store.")
|
|
83
|
+
if self.platform == "ios":
|
|
84
|
+
apple_language(self.language, self.country)
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AppInput(Payload):
|
|
89
|
+
name: str = Field(min_length=1, max_length=160)
|
|
90
|
+
candidate_tokens: list[str] = Field(min_length=1, max_length=2)
|
|
91
|
+
aliases: list[str] = Field(default_factory=list, max_length=20)
|
|
92
|
+
website: str = Field(default="", max_length=500)
|
|
93
|
+
|
|
94
|
+
@field_validator("website")
|
|
95
|
+
@classmethod
|
|
96
|
+
def valid_website(cls, value):
|
|
97
|
+
if value and not http_url(value):
|
|
98
|
+
raise ValueError("Use a full http:// or https:// website URL.")
|
|
99
|
+
return value
|
|
100
|
+
|
|
101
|
+
@field_validator("aliases")
|
|
102
|
+
@classmethod
|
|
103
|
+
def valid_aliases(cls, values):
|
|
104
|
+
if any(not 2 <= len(value.strip()) <= 160 for value in values):
|
|
105
|
+
raise ValueError("Aliases must contain 2 to 160 characters.")
|
|
106
|
+
return list(dict.fromkeys(value.strip() for value in values))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AppEdit(Payload):
|
|
110
|
+
name: str = Field(min_length=1, max_length=160)
|
|
111
|
+
aliases: list[str] = Field(default_factory=list, max_length=20)
|
|
112
|
+
website: str = ""
|
|
113
|
+
archived: bool = False
|
|
114
|
+
valid_website = field_validator("website")(AppInput.valid_website.__func__)
|
|
115
|
+
valid_aliases = field_validator("aliases")(AppInput.valid_aliases.__func__)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CompetitorInput(Payload):
|
|
119
|
+
existing_app_id: int | None = Field(default=None, ge=1)
|
|
120
|
+
app: AppInput | None = None
|
|
121
|
+
monitor_ids: list[int] = Field(default_factory=list, max_length=2000)
|
|
122
|
+
|
|
123
|
+
@model_validator(mode="after")
|
|
124
|
+
def choose_app(self):
|
|
125
|
+
if (self.existing_app_id is None) == (self.app is None):
|
|
126
|
+
raise ValueError("Choose an existing app or connect a new competitor.")
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class MonitorInput(Payload):
|
|
131
|
+
query: str = Field(min_length=1, max_length=500)
|
|
132
|
+
source: Source
|
|
133
|
+
country: str = Field(default="us", pattern=r"^[a-z]{2}$")
|
|
134
|
+
language: str = Field(default="en", pattern=r"^[a-z]{2,3}(?:-[a-z0-9]{2,3})?$")
|
|
135
|
+
device: Literal["", "desktop", "mobile", "tablet"] = ""
|
|
136
|
+
depth: int = Field(default=1, ge=1, le=4)
|
|
137
|
+
frequency: Frequency = "daily"
|
|
138
|
+
app_ids: list[int] = Field(min_length=1, max_length=100)
|
|
139
|
+
|
|
140
|
+
@model_validator(mode="after")
|
|
141
|
+
def source_options(self):
|
|
142
|
+
if self.source == "apple_app_store":
|
|
143
|
+
apple_language(self.language, self.country)
|
|
144
|
+
if not supported_country(self.source, self.country):
|
|
145
|
+
raise ValueError("Choose a supported country for this source.")
|
|
146
|
+
if self.source == "google_play" and self.depth > 3:
|
|
147
|
+
raise ValueError("Google Play supports at most three pages per check in AppTrail.")
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class MonitorBatch(Payload):
|
|
152
|
+
monitors: list[MonitorInput] = Field(min_length=1, max_length=2000)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class MonitorEdit(Payload):
|
|
156
|
+
frequency: Frequency | None = None
|
|
157
|
+
enabled: bool | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class MonitorGroup(Payload):
|
|
161
|
+
monitor_ids: list[int] = Field(min_length=1, max_length=2000)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class MonitorGroupEdit(MonitorGroup, MonitorEdit):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class SyncEdit(Payload):
|
|
169
|
+
paused: bool
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class TargetsInput(Payload):
|
|
173
|
+
app_ids: list[int] = Field(min_length=1, max_length=100)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_app(directory=None, *, start_worker=True, gateway_factory=Gateway):
|
|
177
|
+
config = Config(directory)
|
|
178
|
+
lock = FileLock(config.directory / "instance.lock", thread_local=False)
|
|
179
|
+
if start_worker:
|
|
180
|
+
try:
|
|
181
|
+
lock.acquire(timeout=0)
|
|
182
|
+
except Timeout:
|
|
183
|
+
raise RuntimeError("AppTrail is already running with this data directory.") from None
|
|
184
|
+
db = None
|
|
185
|
+
try:
|
|
186
|
+
db = Database(config.directory)
|
|
187
|
+
auth = Auth(db, config)
|
|
188
|
+
except BaseException:
|
|
189
|
+
if db is not None:
|
|
190
|
+
db.close()
|
|
191
|
+
lock.release()
|
|
192
|
+
raise
|
|
193
|
+
service = Service(db, config, gateway_factory)
|
|
194
|
+
worker = Worker(service)
|
|
195
|
+
|
|
196
|
+
@asynccontextmanager
|
|
197
|
+
async def lifespan(app):
|
|
198
|
+
try:
|
|
199
|
+
if start_worker:
|
|
200
|
+
worker.start()
|
|
201
|
+
yield
|
|
202
|
+
finally:
|
|
203
|
+
if start_worker:
|
|
204
|
+
try:
|
|
205
|
+
worker.stop()
|
|
206
|
+
finally:
|
|
207
|
+
lock.release()
|
|
208
|
+
db.close()
|
|
209
|
+
|
|
210
|
+
app = FastAPI(
|
|
211
|
+
title="AppTrail",
|
|
212
|
+
version=__version__,
|
|
213
|
+
lifespan=lifespan,
|
|
214
|
+
docs_url=None,
|
|
215
|
+
redoc_url=None,
|
|
216
|
+
openapi_url=None,
|
|
217
|
+
)
|
|
218
|
+
app.state.service, app.state.worker, app.state.auth = service, worker, auth
|
|
219
|
+
|
|
220
|
+
@app.middleware("http")
|
|
221
|
+
async def security(request: Request, call_next):
|
|
222
|
+
response = await auth.guard(request, call_next)
|
|
223
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
224
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
225
|
+
response.headers["Referrer-Policy"] = "no-referrer"
|
|
226
|
+
response.headers["Content-Security-Policy"] = (
|
|
227
|
+
"default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' https: data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
|
|
228
|
+
)
|
|
229
|
+
response.headers["Cache-Control"] = "no-store"
|
|
230
|
+
return response
|
|
231
|
+
|
|
232
|
+
@app.exception_handler(RequestValidationError)
|
|
233
|
+
async def invalid(request, exc):
|
|
234
|
+
# Pydantic's default errors include submitted input, including API keys.
|
|
235
|
+
return JSONResponse(
|
|
236
|
+
{"detail": "; ".join(error["msg"] for error in exc.errors())}, status_code=422
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
@app.exception_handler(ProviderError)
|
|
240
|
+
async def provider_error(request, exc):
|
|
241
|
+
return JSONResponse({"detail": str(exc)}, status_code=502)
|
|
242
|
+
|
|
243
|
+
@app.exception_handler(ValueError)
|
|
244
|
+
async def value_error(request, exc):
|
|
245
|
+
return JSONResponse({"detail": str(exc)}, status_code=422)
|
|
246
|
+
|
|
247
|
+
@app.exception_handler(IntegrityError)
|
|
248
|
+
async def duplicate(request, exc):
|
|
249
|
+
return JSONResponse({"detail": "This app or query is already tracked."}, status_code=409)
|
|
250
|
+
|
|
251
|
+
@app.get("/healthz")
|
|
252
|
+
def health():
|
|
253
|
+
return {"status": "ok", "version": __version__}
|
|
254
|
+
|
|
255
|
+
@app.get("/api/state")
|
|
256
|
+
def state():
|
|
257
|
+
return {**service.state(), "version": __version__}
|
|
258
|
+
|
|
259
|
+
@app.post("/api/onboarding")
|
|
260
|
+
def onboarding(payload: OnboardingInput):
|
|
261
|
+
service.update_onboarding(payload.action, payload.step)
|
|
262
|
+
return service.state()["onboarding"]
|
|
263
|
+
|
|
264
|
+
@app.post("/api/hints/{hint}/dismiss")
|
|
265
|
+
def dismiss_hint(hint: Literal["listing-history"]):
|
|
266
|
+
with db.session.begin() as session:
|
|
267
|
+
session.merge(Setting(key="dismissed_hints", value=[hint]))
|
|
268
|
+
return {"ok": True}
|
|
269
|
+
|
|
270
|
+
@app.post("/api/key")
|
|
271
|
+
def save_key(payload: KeyInput):
|
|
272
|
+
key = payload.api_key.get_secret_value().strip()
|
|
273
|
+
if not key:
|
|
274
|
+
raise ValueError("Enter a SerpApi API key.")
|
|
275
|
+
if config.env_key:
|
|
276
|
+
raise ValueError("The key is managed by the environment. Update it there and restart.")
|
|
277
|
+
account = service.account(key)
|
|
278
|
+
config.save_key(key)
|
|
279
|
+
return {"account": account}
|
|
280
|
+
|
|
281
|
+
@app.post("/api/account/refresh")
|
|
282
|
+
def account():
|
|
283
|
+
return service.account()
|
|
284
|
+
|
|
285
|
+
@app.post("/api/discover")
|
|
286
|
+
def discover(payload: DiscoveryInput):
|
|
287
|
+
return {"candidates": service.discover(**payload.model_dump())}
|
|
288
|
+
|
|
289
|
+
@app.post("/api/apps", status_code=201)
|
|
290
|
+
def add_app(payload: AppInput):
|
|
291
|
+
return {
|
|
292
|
+
"id": service.save_app(
|
|
293
|
+
payload.name, payload.candidate_tokens, payload.aliases, payload.website
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@app.post("/api/apps/{app_id}/listings", status_code=201)
|
|
298
|
+
def add_listing(app_id: int, payload: AppInput):
|
|
299
|
+
return {
|
|
300
|
+
"id": service.save_app(
|
|
301
|
+
payload.name, payload.candidate_tokens, payload.aliases, payload.website, app_id
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@app.post("/api/apps/{app_id}/competitors", status_code=201)
|
|
306
|
+
def add_competitor(app_id: int, payload: CompetitorInput):
|
|
307
|
+
if payload.app is not None:
|
|
308
|
+
item = payload.app
|
|
309
|
+
competitor_id = service.save_app(
|
|
310
|
+
item.name,
|
|
311
|
+
item.candidate_tokens,
|
|
312
|
+
item.aliases,
|
|
313
|
+
item.website,
|
|
314
|
+
competitor_for=app_id,
|
|
315
|
+
monitor_ids=payload.monitor_ids,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
competitor_id = payload.existing_app_id
|
|
319
|
+
service.add_competitor(app_id, competitor_id, payload.monitor_ids)
|
|
320
|
+
return {"id": competitor_id, "monitor_ids": list(dict.fromkeys(payload.monitor_ids))}
|
|
321
|
+
|
|
322
|
+
@app.delete("/api/apps/{app_id}/competitors/{competitor_id}")
|
|
323
|
+
def remove_competitor(app_id: int, competitor_id: int):
|
|
324
|
+
with db.session.begin() as session:
|
|
325
|
+
session.execute(text("BEGIN IMMEDIATE"))
|
|
326
|
+
if not session.get(App, app_id) or not session.get(App, competitor_id):
|
|
327
|
+
raise HTTPException(404, "App not found.")
|
|
328
|
+
relation = session.get(Competitor, (app_id, competitor_id))
|
|
329
|
+
if relation:
|
|
330
|
+
session.delete(relation)
|
|
331
|
+
return {"ok": True}
|
|
332
|
+
|
|
333
|
+
@app.patch("/api/apps/{app_id}")
|
|
334
|
+
def edit_app(app_id: int, payload: AppEdit):
|
|
335
|
+
with db.session.begin() as session:
|
|
336
|
+
item = session.get(App, app_id)
|
|
337
|
+
if not item:
|
|
338
|
+
raise HTTPException(404, "App not found.")
|
|
339
|
+
for key, value in payload.model_dump(exclude_unset=True).items():
|
|
340
|
+
setattr(item, key, value)
|
|
341
|
+
if payload.archived:
|
|
342
|
+
session.flush()
|
|
343
|
+
monitor_ids = session.scalars(
|
|
344
|
+
select(Target.monitor_id).where(Target.app_id == app_id)
|
|
345
|
+
).all()
|
|
346
|
+
for monitor_id in monitor_ids:
|
|
347
|
+
if not service.has_active_target(session, monitor_id):
|
|
348
|
+
service.cancel_runs(session, Run.monitor_id == monitor_id)
|
|
349
|
+
listing_ids = select(Listing.id).where(Listing.app_id == app_id)
|
|
350
|
+
service.cancel_runs(session, Run.listing_id.in_(listing_ids))
|
|
351
|
+
watch_ids = select(ListingWatch.id).where(ListingWatch.listing_id.in_(listing_ids))
|
|
352
|
+
service.cancel_runs(session, Run.watch_id.in_(watch_ids))
|
|
353
|
+
return {"ok": True}
|
|
354
|
+
|
|
355
|
+
@app.post("/api/apps/{app_id}/reanalyze")
|
|
356
|
+
def reanalyze(app_id: int):
|
|
357
|
+
with db.session.begin() as session:
|
|
358
|
+
item = session.get(App, app_id)
|
|
359
|
+
if not item:
|
|
360
|
+
raise HTTPException(404, "App not found.")
|
|
361
|
+
listings = session.scalars(select(Listing).where(Listing.app_id == app_id)).all()
|
|
362
|
+
monitors = select(Target.monitor_id).where(Target.app_id == app_id)
|
|
363
|
+
count = 0
|
|
364
|
+
for run in session.scalars(
|
|
365
|
+
select(Run).where(Run.monitor_id.in_(monitors), Run.status == "success")
|
|
366
|
+
):
|
|
367
|
+
session.merge(
|
|
368
|
+
Observation(
|
|
369
|
+
run_id=run.id,
|
|
370
|
+
app_id=app_id,
|
|
371
|
+
data=match_app(item, listings, run.result),
|
|
372
|
+
retrospective=True,
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
count += 1
|
|
376
|
+
return {"updated": count}
|
|
377
|
+
|
|
378
|
+
@app.post("/api/apps/{app_id}/refresh")
|
|
379
|
+
def refresh_profiles(app_id: int):
|
|
380
|
+
with db.session.begin() as session:
|
|
381
|
+
if not session.get(App, app_id):
|
|
382
|
+
raise HTTPException(404, "App not found.")
|
|
383
|
+
ids = []
|
|
384
|
+
for listing in session.scalars(select(Listing).where(Listing.app_id == app_id)):
|
|
385
|
+
existing = session.scalar(
|
|
386
|
+
select(Run).where(
|
|
387
|
+
Run.listing_id == listing.id, Run.status.in_(["queued", "running"])
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
if existing:
|
|
391
|
+
existing.params = {**existing.params, "manual": True}
|
|
392
|
+
ids.append(existing.id)
|
|
393
|
+
continue
|
|
394
|
+
run = Run(
|
|
395
|
+
kind="profile",
|
|
396
|
+
listing_id=listing.id,
|
|
397
|
+
params={
|
|
398
|
+
**{
|
|
399
|
+
key: getattr(listing, key)
|
|
400
|
+
for key in ("platform", "external_id", "country", "language")
|
|
401
|
+
},
|
|
402
|
+
"manual": True,
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
session.add(run)
|
|
406
|
+
session.flush()
|
|
407
|
+
ids.append(run.id)
|
|
408
|
+
return {"run_ids": ids}
|
|
409
|
+
|
|
410
|
+
@app.get("/api/apps/{app_id}/profiles")
|
|
411
|
+
def listing_profiles(app_id: int):
|
|
412
|
+
with db.session() as session:
|
|
413
|
+
if not session.get(App, app_id):
|
|
414
|
+
raise HTTPException(404, "App not found.")
|
|
415
|
+
return {"profiles": service.profile_history(session, app_id)}
|
|
416
|
+
|
|
417
|
+
@app.post("/api/monitors", status_code=201)
|
|
418
|
+
def add_monitor(payload: MonitorInput):
|
|
419
|
+
data = payload.model_dump()
|
|
420
|
+
frequency, app_ids = data.pop("frequency"), data.pop("app_ids")
|
|
421
|
+
return service.add_monitor(data, frequency, app_ids)
|
|
422
|
+
|
|
423
|
+
@app.get("/api/regions")
|
|
424
|
+
def regions():
|
|
425
|
+
return REGIONS
|
|
426
|
+
|
|
427
|
+
@app.post("/api/monitors/batch", status_code=201)
|
|
428
|
+
def add_monitors(payload: MonitorBatch):
|
|
429
|
+
results = service.add_monitors([item.model_dump() for item in payload.monitors])
|
|
430
|
+
return {"monitors": results, "created": sum(not item["reused"] for item in results)}
|
|
431
|
+
|
|
432
|
+
@app.patch("/api/monitors/{monitor_id}")
|
|
433
|
+
def edit_monitor(monitor_id: int, payload: MonitorEdit):
|
|
434
|
+
with db.session.begin() as session:
|
|
435
|
+
item = session.get(Monitor, monitor_id)
|
|
436
|
+
if not item:
|
|
437
|
+
raise HTTPException(404, "Query not found.")
|
|
438
|
+
service.edit_monitors(session, [item], **payload.model_dump())
|
|
439
|
+
return {"ok": True}
|
|
440
|
+
|
|
441
|
+
@app.patch("/api/monitor-groups")
|
|
442
|
+
def edit_monitor_group(payload: MonitorGroupEdit):
|
|
443
|
+
with db.session.begin() as session:
|
|
444
|
+
session.execute(text("BEGIN IMMEDIATE"))
|
|
445
|
+
items = service.query_group(session, payload.monitor_ids)
|
|
446
|
+
service.edit_monitors(
|
|
447
|
+
session, items, frequency=payload.frequency, enabled=payload.enabled
|
|
448
|
+
)
|
|
449
|
+
return {"ok": True}
|
|
450
|
+
|
|
451
|
+
@app.post("/api/monitor-groups/check")
|
|
452
|
+
def check_monitor_group(payload: MonitorGroup):
|
|
453
|
+
with db.session.begin() as session:
|
|
454
|
+
session.execute(text("BEGIN IMMEDIATE"))
|
|
455
|
+
items = service.query_group(session, payload.monitor_ids)
|
|
456
|
+
ids = [service.enqueue(session, item, resume=True) for item in items]
|
|
457
|
+
return {"run_ids": ids}
|
|
458
|
+
|
|
459
|
+
@app.patch("/api/sync")
|
|
460
|
+
def edit_sync(payload: SyncEdit):
|
|
461
|
+
service.set_sync_paused(payload.paused)
|
|
462
|
+
return {"paused": payload.paused}
|
|
463
|
+
|
|
464
|
+
@app.post("/api/monitors/{monitor_id}/replace")
|
|
465
|
+
def replace_monitor(monitor_id: int, payload: ReplaceQueryInput):
|
|
466
|
+
return service.replace_monitor(monitor_id, payload.query)
|
|
467
|
+
|
|
468
|
+
@app.post("/api/monitors/{monitor_id}/targets")
|
|
469
|
+
def add_targets(monitor_id: int, payload: TargetsInput):
|
|
470
|
+
with db.session.begin() as session:
|
|
471
|
+
monitor = session.get(Monitor, monitor_id)
|
|
472
|
+
if not monitor:
|
|
473
|
+
raise HTTPException(404, "Query not found.")
|
|
474
|
+
apps = service.target_apps(session, monitor.source, payload.app_ids)
|
|
475
|
+
return service.attach_targets(session, monitor, apps)
|
|
476
|
+
|
|
477
|
+
@app.get("/api/monitors/{monitor_id}/targets")
|
|
478
|
+
def search_targets(monitor_id: int):
|
|
479
|
+
with db.session() as session:
|
|
480
|
+
monitor = session.get(Monitor, monitor_id)
|
|
481
|
+
if not monitor:
|
|
482
|
+
raise HTTPException(404, "Query not found.")
|
|
483
|
+
run = session.scalar(
|
|
484
|
+
select(Run)
|
|
485
|
+
.where(Run.monitor_id == monitor_id, Run.status == "success")
|
|
486
|
+
.order_by(Run.finished_at.desc(), Run.id.desc())
|
|
487
|
+
)
|
|
488
|
+
apps = session.scalars(
|
|
489
|
+
select(App).join(Target).where(Target.monitor_id == monitor_id).order_by(App.name)
|
|
490
|
+
).all()
|
|
491
|
+
targets = []
|
|
492
|
+
for item in apps:
|
|
493
|
+
observation = session.get(Observation, (run.id, item.id)) if run else None
|
|
494
|
+
targets.append(
|
|
495
|
+
{
|
|
496
|
+
"app_id": item.id,
|
|
497
|
+
"data": observation.data if observation else None,
|
|
498
|
+
"retrospective": observation.retrospective if observation else False,
|
|
499
|
+
}
|
|
500
|
+
)
|
|
501
|
+
return {
|
|
502
|
+
"monitor": record(monitor),
|
|
503
|
+
"targets": targets,
|
|
504
|
+
"run_id": run.id if run else None,
|
|
505
|
+
"checked_at": (run.started_at or run.created_at) if run else None,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@app.delete("/api/monitors/{monitor_id}/targets/{app_id}")
|
|
509
|
+
def detach(monitor_id: int, app_id: int):
|
|
510
|
+
with db.session.begin() as session:
|
|
511
|
+
target = session.get(Target, (monitor_id, app_id))
|
|
512
|
+
if not target:
|
|
513
|
+
raise HTTPException(404, "Tracking target not found.")
|
|
514
|
+
session.delete(target)
|
|
515
|
+
session.flush()
|
|
516
|
+
if not service.has_active_target(session, monitor_id):
|
|
517
|
+
service.cancel_runs(session, Run.monitor_id == monitor_id)
|
|
518
|
+
return {"ok": True}
|
|
519
|
+
|
|
520
|
+
@app.post("/api/monitors/{monitor_id}/check")
|
|
521
|
+
def check(monitor_id: int):
|
|
522
|
+
with db.session.begin() as session:
|
|
523
|
+
item = session.get(Monitor, monitor_id)
|
|
524
|
+
if not item:
|
|
525
|
+
raise HTTPException(404, "Query not found.")
|
|
526
|
+
return {"run_id": service.enqueue(session, item, resume=True)}
|
|
527
|
+
|
|
528
|
+
@app.post("/api/check")
|
|
529
|
+
def check_all(app_id: int | None = None, country: str | None = None, source: str | None = None):
|
|
530
|
+
with db.session.begin() as session:
|
|
531
|
+
active = select(Target.monitor_id).join(App).where(App.archived.is_(False))
|
|
532
|
+
if app_id:
|
|
533
|
+
active = active.where(Target.app_id == app_id)
|
|
534
|
+
query = select(Monitor).where(Monitor.enabled.is_(True), Monitor.id.in_(active))
|
|
535
|
+
if country:
|
|
536
|
+
query = query.where(Monitor.country == country)
|
|
537
|
+
if source:
|
|
538
|
+
query = query.where(Monitor.source == source)
|
|
539
|
+
ids = [service.enqueue(session, item, resume=True) for item in session.scalars(query)]
|
|
540
|
+
return {"run_ids": ids}
|
|
541
|
+
|
|
542
|
+
@app.get("/api/dashboard")
|
|
543
|
+
def dashboard(
|
|
544
|
+
app_id: int | None = None,
|
|
545
|
+
days: int = Query(30, ge=1, le=3650),
|
|
546
|
+
start: float | None = None,
|
|
547
|
+
end: float | None = None,
|
|
548
|
+
country: str | None = None,
|
|
549
|
+
source: str | None = None,
|
|
550
|
+
):
|
|
551
|
+
return service.dashboard(
|
|
552
|
+
app_id, start if start is not None else now() - days * 86400, end, country, source
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
@app.get("/api/runs/{run_id}")
|
|
556
|
+
def run_details(run_id: int):
|
|
557
|
+
with db.session() as session:
|
|
558
|
+
item = session.get(Run, run_id)
|
|
559
|
+
if not item:
|
|
560
|
+
raise HTTPException(404, "Run not found.")
|
|
561
|
+
return {
|
|
562
|
+
**record(item),
|
|
563
|
+
**(
|
|
564
|
+
{
|
|
565
|
+
"listing_snapshot_id": session.scalar(
|
|
566
|
+
select(ListingSnapshot.id).where(ListingSnapshot.run_id == item.id)
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
if item.kind == "listing_history"
|
|
570
|
+
else {}
|
|
571
|
+
),
|
|
572
|
+
"observations": [
|
|
573
|
+
record(obs)
|
|
574
|
+
for obs in session.scalars(
|
|
575
|
+
select(Observation).where(Observation.run_id == run_id)
|
|
576
|
+
)
|
|
577
|
+
],
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@app.get("/api/export.csv")
|
|
581
|
+
def export(app_id: int | None = None, country: str | None = None, source: str | None = None):
|
|
582
|
+
with tempfile.NamedTemporaryFile(suffix=".csv", delete=False) as handle:
|
|
583
|
+
path = Path(handle.name)
|
|
584
|
+
try:
|
|
585
|
+
with path.open("w", encoding="utf-8", newline="") as output:
|
|
586
|
+
writer = csv.writer(output)
|
|
587
|
+
writer.writerow(
|
|
588
|
+
[
|
|
589
|
+
"checked_at",
|
|
590
|
+
"app_id",
|
|
591
|
+
"query",
|
|
592
|
+
"source",
|
|
593
|
+
"country",
|
|
594
|
+
"status",
|
|
595
|
+
"position",
|
|
596
|
+
"mentioned",
|
|
597
|
+
"cited",
|
|
598
|
+
"answer_available",
|
|
599
|
+
"app_name",
|
|
600
|
+
"monitor_id",
|
|
601
|
+
"language",
|
|
602
|
+
"device",
|
|
603
|
+
"depth",
|
|
604
|
+
"found",
|
|
605
|
+
"section",
|
|
606
|
+
"app_link",
|
|
607
|
+
"website_cited",
|
|
608
|
+
"retrospective",
|
|
609
|
+
]
|
|
610
|
+
)
|
|
611
|
+
for row in service.export_observations(app_id, country, source):
|
|
612
|
+
values = [
|
|
613
|
+
datetime.fromtimestamp(row["checked_at"], UTC).isoformat(),
|
|
614
|
+
row["app_id"],
|
|
615
|
+
row["params"]["query"],
|
|
616
|
+
row["params"]["source"],
|
|
617
|
+
row["params"]["country"],
|
|
618
|
+
row["status"],
|
|
619
|
+
*[
|
|
620
|
+
row["data"].get(k, "")
|
|
621
|
+
for k in ("position", "mentioned", "cited", "answer_available")
|
|
622
|
+
],
|
|
623
|
+
row["app_name"],
|
|
624
|
+
row["monitor_id"],
|
|
625
|
+
*[row["params"].get(k, "") for k in ("language", "device", "depth")],
|
|
626
|
+
*[
|
|
627
|
+
row["data"].get(k, "")
|
|
628
|
+
for k in ("found", "section", "app_link", "website_cited")
|
|
629
|
+
],
|
|
630
|
+
row["retrospective"],
|
|
631
|
+
]
|
|
632
|
+
writer.writerow(
|
|
633
|
+
[
|
|
634
|
+
"'" + value
|
|
635
|
+
if isinstance(value, str)
|
|
636
|
+
and value.startswith(("=", "+", "-", "@", "\t", "\r", "\n"))
|
|
637
|
+
else value
|
|
638
|
+
for value in values
|
|
639
|
+
]
|
|
640
|
+
)
|
|
641
|
+
except BaseException:
|
|
642
|
+
path.unlink(missing_ok=True)
|
|
643
|
+
raise
|
|
644
|
+
return FileResponse(
|
|
645
|
+
path,
|
|
646
|
+
filename="apptrail-history.csv",
|
|
647
|
+
media_type="text/csv",
|
|
648
|
+
background=BackgroundTask(path.unlink, missing_ok=True),
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
@app.get("/api/backup")
|
|
652
|
+
def backup():
|
|
653
|
+
with tempfile.NamedTemporaryFile(suffix=".sqlite3", delete=False) as handle:
|
|
654
|
+
path = Path(handle.name)
|
|
655
|
+
db.backup(path, include_sessions=False)
|
|
656
|
+
return FileResponse(
|
|
657
|
+
path,
|
|
658
|
+
filename="apptrail-backup.sqlite3",
|
|
659
|
+
media_type="application/vnd.sqlite3",
|
|
660
|
+
background=BackgroundTask(path.unlink, missing_ok=True),
|
|
661
|
+
)
|
|
662
|
+
|
|
663
|
+
static = Path(__file__).parent / "static"
|
|
664
|
+
from .insights_api import register_insights
|
|
665
|
+
|
|
666
|
+
register_insights(app, service)
|
|
667
|
+
auth.routes(app, static)
|
|
668
|
+
app.mount("/static", StaticFiles(directory=static), name="static")
|
|
669
|
+
|
|
670
|
+
@app.get("/")
|
|
671
|
+
def index():
|
|
672
|
+
return FileResponse(static / "index.html")
|
|
673
|
+
|
|
674
|
+
return app
|