OpenFindability v0.1 is a simple local Next.js app, not a monorepo.
Current goal:
Google Search Console + Umami + projects + manual sync + SEO opportunities.
ASO research uses local RespectASO and reusable local keyword cache.
RevenueCat + AdMob connectors for app monetization stats.Avoid adding advanced architecture before the base product is useful.
Useful commands:
pnpm seed:demo
pnpm dev
pnpm run doctor
pnpm run sync
pnpm run audit:index
pnpm run sync:aso
pnpm run sync:revenuecat
pnpm run sync:admob
pnpm run sync:admob:backfill
pnpm run admob:auth
pnpm run admob:apps
pnpm run sync:adsense
pnpm run sync:adsense:backfill
pnpm run adsense:auth
pnpm run adsense:accounts
pnpm run asc:apps
pnpm run aso:pull-copy -- --slug <project-slug>
pnpm run aso:push-copy -- --slug <project-slug> --locale it [...] [--apply]
pnpm run asc:experiments -- --slug <project-slug>
pnpm run research:aso -- --slug <project-slug>
pnpm run db:generate
pnpm run db:studio
pnpm run db:migrate-json
pnpm typecheck
pnpm buildImportant implementation notes:
- Data lives in
data/openfindability.db, a local SQLite database accessed through Drizzle ORM (better-sqlite3driver). WAL journal mode andforeign_keys = ONare set at connection time inlib/db/client.ts. - Runtime data is ignored by git.
- Public docs live in
docs/. - Private project files live in
project/<project-slug>/with subfolders:reports/,context/,notes/. - Dated project analysis reports belong in
project/<project-slug>/reports/. - Private scratch notes live in
private-notes/and are ignored by git. - Service account JSON files live in
secrets/google/. - GSC backfill defaults to 30 days.
- GSC sync also pulls device/country/searchAppearance breakdowns (aggregated over the whole synced range, not daily) into
gscDimensionBreakdowns, plus sitemap status (sitemaps.list) intogscSitemaps. The GSC report (buildGscReportMarkdown) renders these as extra sections when present. - Manual GSC index auditing is implemented by
pnpm run audit:index [max-urls-per-site] [slug-or-property-filter](scripts/gsc-index-audit.ts+lib/gsc-index-audit.ts). It lists every GSC property visible to the service account (including properties not mapped to an OpenFindability project), discovers URLs from submitted sitemap XML, 90-day Search Analytics pages, stored GSC rows and audit history, and calls URL Inspection with a default/maximum budget of 2,000 URLs per property/day. Same-day rows are skipped; never-inspected URLs come first, then existing high/medium problems and older successes, so large properties make progressive coverage rather than exhausting every new day's quota on the same issues. Results and raw API payloads live ingscIndexInspections(projectIdnullable,siteUrlalways present);connectorRuns.sourceacceptsgsc_index. Reports are written globally to ignoredprivate-notes/index-audits/and toproject/<slug>/reports/; unmapped properties derive the slug from their domain (for example VitaRomagna writes toproject/vitaromagna/reports/).pnpm run report -- <slug> indexregenerates reports for configured projects. The dashboard shows latest-state totals/problems globally and per project. This is the broadest public-API audit possible, not Google's unavailable bulk Page Indexing inventory. - The index audit runs 16 URL Inspection calls concurrently (well below the official 600 QPM/property quota at observed API latency) and checkpoints the full store every 500 completed inspections so a late failure does not discard a large in-memory batch. Project reports also surface each stored sitemap's submitted/indexed counts from its preserved raw GSC payload.
- The GSC report includes a Trend section (mirrors the ASO one) comparing page-level metrics between the two most recent
pageMetricssnapshot dates per project (clicks/impressions/avg position delta), shown only when at least two dates exist.formatDelta(lib/report.ts) takes an optionaldecimalsoption so position deltas round to 1 decimal instead of showing long floats; ASO's integer scores are unaffected (defaultdecimals: 0). - Umami sync imports yesterday.
- Keep
rawJsonon imported records. - Store connector run logs.
- Do not add SaaS-specific code yet.
- ASO data comes from a local RespectASO instance (not SaaS); see
docs/guide/aso-workflow.md. - Configure
asoKeywords/asoCountries/appStoreTrackId/respectAsoAppIdper project when needed. - ASO keyword metrics are cached in
asoKeywordSnapshotsfor reuse across projects. - App-specific ASO ranks are cached separately in
asoAppRankSnapshots. pnpm run research:asowrites reports toproject/<slug>/reports/and can reuse cache unless--refreshis passed.- The ASO report (
buildAsoReportMarkdowninlib/report.ts) includes a Trend section comparing the two most recentappKeywordssnapshot dates per project (rank/opportunity/popularity delta), shown only when at least two dates exist. - RevenueCat and AdMob connectors (
lib/connectors/revenuecat.ts,lib/connectors/admob.ts) are third-party API connectors, not SaaS-specific code — they follow the same pattern as GSC/Umami/Play Console; seedocs/guide/monetization-workflow.md. - Configure
revenueCatProjectId/admobAppIdper project when needed. - RevenueCat auth is a V2 secret key (
REVENUECAT_API_KEY, Bearer token), not OAuth. - AdMob auth is interactive OAuth2 (
ADMOB_CLIENT_ID/ADMOB_CLIENT_SECRET/ADMOB_REFRESH_TOKEN/ADMOB_PUBLISHER_ID); mint the refresh token once viapnpm run admob:authrun on your own machine (needs a real browser). - RevenueCat's
revenuesnapshot field (revenue_last_28_days) is a rolling window, not a true daily total — never sum it across dates, only use the latest snapshot per project. AdMob'srevenuesnapshot field is a true daily total and is safe to sum across dates/projects. - RevenueCat/AdMob sync as part of default
pnpm run sync(like gsc/umami/play_console); ASO stays opt-in only (pnpm run sync:aso). - When changing commands, workflow, storage shape or folder conventions, update both
AGENTS.mdandCLAUDE.md. - Every new feature must be documented here (and in
AGENTS.mdif it affects rules/structure) in the same change that introduces it. - UI uses Tailwind v4 + a small set of shadcn/ui primitives (
components/ui/: button, card, badge, table, separator, chart), styled to match the same design tokens used in the Wiloo project (components.json,lib/utils.tscn helper). Add new shadcn primitives the same way (copy + adapt import paths) instead of hand-rolling CSS classes. - Charts use Recharts (
^2.15.4, pinned to match the Kiranismnext-shadcn-dashboard-startertemplate Wiloo's design system was ported from) via thecomponents/ui/chart.tsxprimitive (ChartContainer/ChartConfig/ChartTooltip/ChartTooltipContent). Theme colors come from the existing--chart-1..--chart-5CSS variables inapp/globals.css— no new theming needed. Dashboard-specific chart components live incomponents/charts/(e.g.admob-revenue-chart.tsx,revenuecat-mrr-chart.tsx), are"use client", and only receive pre-computed data as props —app/page.tsxstays an async Server Component. lib/insights.tsexposesgetAdmobRevenueTrend(data, days)andgetRevenueCatMrrTrend(data, days)for chart data: both dedupemetricSnapshotsby(projectId, date)(latestcreatedAtwins) before summing across projects per date, since sync does not dedupe on insert. AdMob's dailyrevenueis safe to sum this way; RevenueCat'smrr/activeSubscribersare point-in-time gauges (safe to sum across projects on the same date) — only RevenueCat's rollingrevenuefield must never be aggregated like this.- The home page's "Progetti" table and AdMob/RevenueCat cards now also show a month-over-month/30-day trend badge (plain
Badge, no icons) computed bytrendPercent/formatTrendhelpers inapp/page.tsx. - All of the above is presentation only: it reads
lib/insights.tsaggregates computed fromdata/openfindability.json. Scripts, sync, doctor and reports remain fully usable without the web UI — do not move data logic into client components. pnpm run report -- <slug> monetization(orall) writes a datedproject/<slug>/reports/<date>-monetization-data.mdviabuildMonetizationReportMarkdowninlib/report.ts: AdMob revenue (that day/this month/previous month, true daily sums) and RevenueCat (latest MRR/active subscribers/trials/new customers, rolling 28-day revenue) plus a per-source daily/MRR trend table (last 30 rows). Skipped with a log message if the project has neitheradmobAppIdnorrevenueCatProjectIdconfigured.lib/sync.tsdedupes on every sync run instead of just appending:upsertByKey/upsertSnapshots/upsertQueries/upsertPagesreplace any existing row sharing the same key (metricSnapshots:projectId::source::date;searchQueries:projectId::date::query::page;pageMetrics:projectId::date::page) with the newly synced one before writing. This makes re-runningpnpm run syncthe same day idempotent instead of piling up duplicate rows that would double-count in reports/insights/charts. Play Console'sappReviewskeeps its own existingreviewIddedupe; ASO'sappKeywordsis intentionally left append-only (opt-in, cached upstream).- App navigation is now a sidebar:
components/ui/sidebar.tsxis the Kiranism/shadcnSidebarprimitive (ported the same way as othercomponents/ui/*, depends on newsheet.tsx/tooltip.tsx/skeleton.tsx/input.tsxandhooks/use-mobile.ts; Radix imports adapted to this project's individual-package convention —@radix-ui/react-dialogfor Sheet,@radix-ui/react-tooltipfor Tooltip — not the consolidatedradix-uimeta-package).components/app-sidebar.tsx("use client") lists every project (type-specific icon) linking to/project/<slug>, plus Doctor/Sync manuale shortcuts.app/layout.tsxis an async Server Component readingdata.projectsand wrapping{children}inSidebarProvider/SidebarInset— sinceSidebarInsetrenders<main>, page-level wrappers (app/page.tsx,app/project/[slug]/page.tsx) use a top-level<div>, not<main>. New--sidebar*/--color-sidebar*tokens inapp/globals.cssare derived from the existing warm/green oklch palette, not shadcn's neutral defaults. - Per-project dashboard at
app/project/[slug]/page.tsx: looks up the project by slug (notFound()if missing) and renders a grid of cards, one per data source, scoped to that project. Uses newgetProjectMetricTrend(data, projectId, source, key, days)inlib/insights.ts(single-project counterpart togetAdmobRevenueTrend/getRevenueCatMrrTrend, reusing the same dedupe/sum helpers) plus the existingsummarizeProjectandaggregatePagesByUrl/aggregateQueriesByText(lib/report.ts). Cards: GSC, Umami, AdMob (reusesAdmobRevenueChart), RevenueCat (reusesRevenueCatMrrChart), top pages, top queries, ASO keywords (latestappKeywordsdate, sorted byopportunityScore), Play Store (latestplay_consolesnapshot + recentappReviews), opportunities, recent connector runs. Each card is rendered only if that project actually has rows for that source — otherwise it's omitted entirely, unlike the homepage's AdMob/RevenueCat cards which always render with a "not configured yet" hint. GSC/Umami trend charts use new genericcomponents/charts/metric-trend-chart.tsx(MetricTrendChart, parameterized bylabel/color/valueFormatter, with a per-instance gradient id to avoid SVG id collisions when several trend charts render on one page). - Storage moved from a single hand-rolled JSON file to SQLite (via
better-sqlite3+drizzle-orm/drizzle-kit), to make the data properly relational/queryable while keeping v0.1 simple (no server process, no extra infra — still a single local file).lib/db/schema.tsdefines onesqliteTableperAppDataarray (same 10 tables/shapes aslib/types.ts), withFOREIGN KEY ... ON DELETE CASCADEfrom every project-scoped table toprojects, plus the same unique/non-unique indexes the old code relied on for dedup keys (e.g.metric_snapshots_project_source_date,search_queries_project_date_query_page).lib/db/client.tsopensdata/openfindability.db, sets the WAL + foreign-key pragmas, and runsmigrate()against./drizzleat module load — so importing the store still requires zero manual setup, same as the old JSON file.drizzle.config.ts(dialect: "sqlite") isdrizzle-kit's config for generating migrations fromlib/db/schema.ts; the generated SQL underdrizzle/is committed to git (it's schema history, not runtime data), unlikedata/*.db*which stays gitignored (.db,.db-wal,.db-shm,.db-journal). lib/store.ts's public API is unchanged on purpose (readData(): Promise<AppData>,writeData(data): Promise<void>,updateData(mutator),getDataFilePath()— now re-exportinglib/db/client.ts'sgetDbFilePath), so every consumer (lib/insights.ts,lib/report.ts,lib/doctor.ts,lib/sync.ts,app/*.tsx, everyscripts/*.ts) needed zero changes. Internally:readData()runs onedb.select().from(table).all()per table and normalizes SQLNULLback toundefined(Drizzle always decodesNULLasnull, butAppData's optional fields are typedT | undefined) — exceptAppKeywordMetric.appRank/AsoAppRankSnapshot.appRank, which keep a literalnullsince those fields are explicitly nullable, not just optional.writeData(data)wipes every table and bulk-reinserts the fullAppDataobject in onedb.transaction(...)(chunked ~50 rows per insert to stay under SQLite's bound-parameter limit) — this is semantically identical to the old "overwrite the whole JSON file" behavior because every caller (lib/sync.ts,scripts/seed-demo.ts, etc.) already builds the completeAppDatain memory and callswriteData()exactly once at the end.AppReview.textis stored under the DB column/propertyreviewText(the JS nametextwould collide with Drizzle'stext()column-builder import) and is the one field renamed back and forth inlib/store.ts.scripts/migrate-json-to-sqlite.tsis a one-time helper: reads the legacydata/openfindability.jsonand callswriteData()to import it into SQLite. Run once viapnpm run db:migrate-json, then the legacy.jsonfile can be deleted.next.config.tssetsserverExternalPackages: ["better-sqlite3"]so Turbopack/webpack doesn't try to bundle the native addon for the server build.pnpm run db:studiolaunches Drizzle Studio, a free local GUI SQL browser againstdata/openfindability.db— the most direct way to make the data "well-queryable" ad hoc, on top of the existing CLI scripts/reports.pnpm run db:generateregeneratesdrizzle/*.sqlafter editinglib/db/schema.ts.- This pass is storage-layer only:
lib/insights.ts/lib/report.tsstill aggregate in plain JS over the fullAppDatablob returned byreadData(), not via SQLGROUP BY/SUM. That stays a valid future optimization but wasn't needed forreadData()/writeData()to keep their exact same signatures and 100% backward compatibility. lib/db/client.tsexportscreateDb(dbFilePath, migrationsFolder): AppDb(factory) alongside the module-scope singletondb;lib/store.tsexportsreadDataWith(database)/writeDataWith(database, data)(the same logicreadData/writeDatause internally, parameterized by db instance) alongside thedb-singleton-boundreadData/writeData. This exists formcp/server.ts, which runs viatsxfrom an arbitrary cwd in other repos (seedocs/guide/mcp-server.md) and therefore can't rely onprocess.cwd()-based path resolution like the main app and CLI scripts do — it resolves its owndata/openfindability.db/drizzle/paths fromimport.meta.urlinstead, builds its ownAppDbviacreateDb, and itsreadProjects/readStoredData/writeStoredDatahelpers are thin wrappers aroundreadDataWith/writeDataWithagainst that instance.- Known pre-existing limitation (not introduced by the SQLite migration):
mcp/server.ts's@/lib/...path-aliased imports only resolve correctly viatsxwhen the process's cwd is the OpenFindability repo itself; invoking it withtsx /path/to/OpenFindability/mcp/server.tsfrom a different repo's cwd (the documented "use from any project" flow indocs/guide/mcp-server.md) fails to resolve@/imports regardless of storage layer. Fixing this (e.g. switchingmcp/server.tsto relative imports) is a separate, currently unscheduled fix. - AdMob apps that ship on both stores have two AdMob app ids (Android + iOS);
Project.admobAppIdIoswas added alongside the existingProject.admobAppId(which stays Android/primary) so one OpenFindability project can represent one logical app.syncAdmobProject(lib/connectors/admob.ts) filters the network report to whichever ids are set (dimensionFilters: [{ dimension: "APP", matchesAny: { values: appIds } }]) and sums matching rows into a singlemetricSnapshotsrow per project per day, instead of matching only one hardcoded app id. - AdMob mediation report data (per ad-source/format breakdown — AdMob Network, AppLovin, Unity Ads, Meta, etc. — with ad requests, match rate, impressions, clicks, estimated earnings, observed eCPM) is synced into a new table/type,
admobMediationMetrics/AdmobMediationMetric(lib/types.ts,lib/db/schema.ts), not intometricSnapshots(different shape/grain — one row per project+date+adSource+format, not one row per project+date).syncAdmobProjectnow returns{ result, snapshots, mediationMetrics };lib/sync.tsupsertsadmobMediationMetricskeyed on(projectId, date, adSourceId, format)the same way other sources dedupe on re-sync (upsertMediationMetrics). pnpm run db:generatewas re-run after these schema changes (drizzle/0002_damp_karen_page.sql) — always regenerate + apply (readData()/writeData()auto-runmigrate()at module load, so just re-running any script does it) after editinglib/db/schema.ts.scripts/add-project.tsaccepts--admob-app-id-iosalongside the existing--admob-app-id.pnpm run admob:apps(scripts/admob-list-apps.ts) lists every app registered under the AdMob account (accounts.apps.list) with itsca-app-pub-...~...id, platform and display name — the fastest way to find app ids to configure per project instead of digging through the AdMob UI.syncAdmobProject(project, startDate, endDate)takes a date range, not a single day: both the network report and the mediation report use theDATEdimension so one API call returns one row per day (per app/ad-source/format) for the whole range, which we then group by day inlib/connectors/admob.tsinto onemetricSnapshots/admobMediationMetricsrow per date.lib/sync.tscalls it withdaysAgo(backfillDays)todaysAgo(1), reusing the samebackfillDaysoption (default 30) already shared with GSC/Play Console.scripts/sync.tsaccepts an optional extra CLI arg to override it for a one-time deep backfill;pnpm run sync:admob:backfillis a fixed 1500-day (~4y) version of this baked intopackage.json(pnpm's--arg passthrough for custom values mis-quotes on Windows/PowerShell — usenpx tsx scripts/sync.ts admob <days>directly instead). AdMob simply returns fewer rows than requested once it runs out of real history — asking for more days than the account/app has data for is harmless.- AdSense connector (
lib/connectors/adsense.ts, source"adsense") — for site display-ad revenue, separate from AdMob (app ads). AdSense Management API v2 has no OAuth-free service-account option either: same interactive OAuth2 pattern as AdMob (ADSENSE_CLIENT_ID/ADSENSE_CLIENT_SECRET/ADSENSE_REFRESH_TOKEN, minted once viapnpm run adsense:auth, needs a real browser) plusADSENSE_ACCOUNT_ID(barepub-...id, discoverable viapnpm run adsense:accounts→accounts.list(); the connector prefixes it toaccounts/${id}itself, mirroring howADMOB_PUBLISHER_IDis stored bare and prefixed inlib/connectors/admob.ts). Enable "AdSense Management API" (adsense.googleapis.com) in Cloud Console — not "AdSense Platform API" (adsenseplatform.googleapis.com), which is for reseller/multi-account platforms, the wrong product for reading your own account's data. - A project maps to AdSense via
Project.adsenseSiteDomain(a domain string likeexample.com), not an app id — AdSense reports on websites, not apps.syncAdsenseProject(project, startDate, endDate)callsaccounts.reports.generatewith aDATEdimension (same range-in-one-call pattern as AdMob) filtered byOWNED_SITE_DOMAIN_NAME==<domain>, and mapsESTIMATED_EARNINGS/PAGE_VIEWS/IMPRESSIONS/CLICKS/AD_REQUESTSinto the existingmetricSnapshotsshape (pageviewsreused the same way Umami uses it) — no new table needed, unlike AdMob's separate mediation table. AdSense's response shape is GET-style query params + a{headers, rows}result (dimension/metric name → column index), not AdMob's POST body + streamed[{header},{row},...,{footer}]— the two connectors share the pattern (OAuth2, date-range backfill,Projectfield → filter) but not request/response code. - App Store Connect API connector (
lib/connectors/appstoreconnect.ts) reads/writes live App Store product text and manages Product Page Optimization (PPO) tests; full workflow indocs/guide/appstoreconnect-workflow.md. Auth: ES256 JWT built withnode:cryptoonly (createSign("sha256").sign({ key, dsaEncoding: "ieee-p1363" })gives the raw JOSE r‖s signature directly, so nojsonwebtokendependency was added), fromASC_ISSUER_ID/ASC_KEY_ID/ASC_PRIVATE_KEY_PATH— the.p8key lives undersecrets/appstoreconnect/(mirrorssecrets/google/). No newProjectfield: it reuses the existingappStoreTrackIdas the App Store Connect app resource id (Apple's ASCidfor an app is the same numeric "Apple ID").pnpm run asc:appslists apps visible to the key to confirm the mapping. - Apple's PPO A/B-tests app icon/screenshots/app previews only — never text (name/subtitle/description/keywords). This is a hard Apple platform limitation, not a gap in this connector. Text changes are therefore a read/write-with-history workflow, not a live split test:
pnpm run aso:pull-copy -- --slug <slug>reads current App Info (name/subtitle) + the current editable App Store version's localization (keywords/description/promotionalText/whatsNew) and stores an append-onlyascMetadataSnapshotsrow (kind: "pull") per locale, plus aproject/<slug>/reports/<date>-appstoreconnect-copy.mddump.pnpm run aso:push-copy -- --slug <slug> --locale it [fields...] [--apply]is dry-run by default (prints old→new diff, writes nothing) — only--applyPATCHes App Store Connect and stores akind: "push"snapshot; version-scoped fields require an editable version (PREPARE_FOR_SUBMISSION/rejected state) to already exist, since writes never touch a live version. - PPO experiments/treatments sync into two new tables,
ascExperiments/ascExperimentTreatments(lib/types.ts,lib/db/schema.ts;ascExperimentTreatments.experimentIdFKs to the localascExperiments.id, not Apple's id), viapnpm run asc:experiments -- --slug <slug>(full project resync, list-then-replace like a lightweight version oflib/sync.ts's upsert pattern). The same command's--create-experiment "name" --element screenshot|appPreview|appIconand--add-treatment <ascExperimentId> --name "..."flags create real experiment/treatment shells via the API — but attaching the actual icon/screenshot/app-preview creative assets to a treatment is still a manual App Store Connect UI step (no asset-upload pipeline was built), and Apple exposes no results/conversion-metrics endpoint at all — winners are ASC-UI-only, sobuildAscReportMarkdownstates/treatments only, never fabricates a "winner". connectorRuns.sourcegained"asc_metadata"/"asc_experiments"; neither runs as part of defaultpnpm run sync— like ASO, this is an on-demand editorial workflow, not a daily metric pull.pnpm run report -- <slug> ascrendersbuildAscReportMarkdown(lib/report.ts) from whatever has already been pulled/synced above.pnpm run sync:adsense/pnpm run sync:adsense:backfillmirror the AdMob commands exactly (samebackfillDaysdefault, same 1500-day fixed backfill script).adsensewas added toSourceType(lib/types.ts), themetric_snapshots.source/connector_runs.sourceenums (lib/db/schema.ts),scripts/sync.ts'svalidSources, andlib/doctor.ts's credential/staleness checks.