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 ADDED
@@ -0,0 +1,3 @@
1
+ """App visibility tracking, on your own machine."""
2
+
3
+ __version__ = "0.4.0"
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