added views and updated docs

This commit is contained in:
Nuno Coração
2026-01-12 17:42:10 +00:00
parent e5fc548eb5
commit b8fa87eca2
14 changed files with 2579 additions and 26 deletions

12
.gitignore vendored
View File

@@ -23,6 +23,18 @@ exampleSite/resources/
node_modules node_modules
.hugo_build.lock .hugo_build.lock
# Firebase scripts
scripts/serviceAccountKey.json
scripts/node_modules/
scripts/*.csv
# Firebase (sensitive - never commit!)
firebase-service-account.json
# Google Analytics export (temporary)
ga-pageviews.csv
*.csv
# OS generated files # OS generated files
.DS_Store .DS_Store
.DS_Store? .DS_Store?

View File

@@ -63,7 +63,7 @@ fingerprintAlgorithm = "sha512" # Valid values are "sha512" (default), "sha384",
[article] [article]
showDate = false showDate = false
showViews = false showViews = true
showLikes = false showLikes = false
showDateOnlyInArticle = false showDateOnlyInArticle = false
showDateUpdated = false showDateUpdated = false
@@ -134,13 +134,13 @@ fingerprintAlgorithm = "sha512" # Valid values are "sha512" (default), "sha384",
cardViewScreenWidth = false cardViewScreenWidth = false
[firebase] [firebase]
#apiKey = "AIzaSyB5tqlqDky77Vb4Tc4apiHV4hRZI18KGiY" apiKey = "AIzaSyB5tqlqDky77Vb4Tc4apiHV4hRZI18KGiY"
#authDomain = "blowfish-21fff.firebaseapp.com" authDomain = "blowfish-21fff.firebaseapp.com"
#projectId = "blowfish-21fff" projectId = "blowfish-21fff"
#storageBucket = "blowfish-21fff.appspot.com" storageBucket = "blowfish-21fff.appspot.com"
#messagingSenderId = "60108104191" messagingSenderId = "60108104191"
#appId = "1:60108104191:web:039842ebe1370698b487ca" appId = "1:60108104191:web:039842ebe1370698b487ca"
#measurementId = "G-PEDMYR1V0K" measurementId = "G-PEDMYR1V0K"
[fathomAnalytics] [fathomAnalytics]
# site = "ABC12345" # site = "ABC12345"

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Firestore einrichten - Wählen Sie Build und öffnen Sie Firestore. Erstellen Sie eine neue Datenbank und wählen Sie den Start im Produktionsmodus. Wählen Sie den Serverstandort und warten Sie. Sobald dies gestartet ist, müssen Sie die Regeln konfigurieren. Kopieren Sie einfach die Datei unten und drücken Sie Veröffentlichen. 5. Firestore einrichten - Wählen Sie Build und öffnen Sie Firestore. Erstellen Sie eine neue Datenbank und wählen Sie den Start im Produktionsmodus. Wählen Sie den Serverstandort und warten Sie. Sobald dies gestartet ist, müssen Sie die Regeln konfigurieren. Kopieren Sie einfach die Datei unten und drücken Sie Veröffentlichen. Diese Regeln stellen sicher, dass Aufrufe nur um 1 erhöht werden können und Likes nur um +1 oder -1 geändert werden können (und nie unter 0 fallen).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Configurar Firestore - Selecciona Build y abre Firestore. Crea una nueva base de datos y elige iniciar en modo producción. Selecciona la ubicación del servidor y espera. Una vez iniciado, necesitas configurar las reglas. Simplemente copia y pega el archivo de abajo y presiona publicar. 5. Configurar Firestore - Selecciona Build y abre Firestore. Crea una nueva base de datos y elige iniciar en modo producción. Selecciona la ubicación del servidor y espera. Una vez iniciado, necesitas configurar las reglas. Simplemente copia y pega el archivo de abajo y presiona publicar. Estas reglas aseguran que las vistas solo pueden incrementarse en 1, y los likes solo pueden cambiarse en +1 o -1 (y nunca bajar de 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Configurer Firestore - Sélectionnez Build et ouvrez Firestore. Créez une nouvelle base de données et choisissez de démarrer en mode production. Sélectionnez l'emplacement du serveur et attendez. Une fois démarré, vous devez configurer les règles. Copiez et collez simplement le fichier ci-dessous et appuyez sur publier. 5. Configurer Firestore - Sélectionnez Build et ouvrez Firestore. Créez une nouvelle base de données et choisissez de démarrer en mode production. Sélectionnez l'emplacement du serveur et attendez. Une fois démarré, vous devez configurer les règles. Copiez et collez simplement le fichier ci-dessous et appuyez sur publier. Ces règles garantissent que les vues ne peuvent être incrémentées que de 1, et les likes ne peuvent être modifiés que de +1 ou -1 (et ne jamais descendre en dessous de 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Setup Firestore - Select Build and open Firestore. Create a new database and choose to start in production mode. Select server location and wait. Once that is started you need to configure the rules. Just copy and paste the file below and press publish. 5. Setup Firestore - Select Build and open Firestore. Create a new database and choose to start in production mode. Select server location and wait. Once that is started you need to configure the rules. Just copy and paste the file below and press publish. These rules ensure that views can only be incremented by 1, and likes can only be changed by +1 or -1 (and never go below 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Firestore を設定する - 「ビルド」を選択して Firestore を開きます。新しいデータベースを作成し、本番モードで開始することを選択します。サーバーの場所を選択して待ちます。開始したら、ルールを設定する必要があります。以下のファイルをコピーして貼り付け、「公開」をクリックします。 5. Firestore を設定する - 「ビルド」を選択して Firestore を開きます。新しいデータベースを作成し、本番モードで開始することを選択します。サーバーの場所を選択して待ちます。開始したら、ルールを設定する必要があります。以下のファイルをコピーして貼り付け、「公開」をクリックします。これらのルールは、閲覧数は1ずつのみ増加でき、いいねは+1または-1のみ変更可能0未満にはならないであることを保証します。
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Setup Firestore - Select Build and open Firestore. Create a new database and choose to start in production mode. Select server location and wait. Once that is started you need to configure the rules. Just copy and paste the file below and press publish. 5. Setup Firestore - Select Build and open Firestore. Create a new database and choose to start in production mode. Select server location and wait. Once that is started you need to configure the rules. Just copy and paste the file below and press publish. These rules ensure that views can only be incremented by 1, and likes can only be changed by +1 or -1 (and never go below 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Configurar Firestore - Selecione Build e abra Firestore. Crie um novo banco de dados e escolha iniciar no modo produção. Selecione a localização do servidor e aguarde. Uma vez iniciado, você precisa configurar as regras. Basta copiar e colar o arquivo abaixo e pressionar publicar. 5. Configurar Firestore - Selecione Build e abra Firestore. Crie um novo banco de dados e escolha iniciar no modo produção. Selecione a localização do servidor e aguarde. Uma vez iniciado, você precisa configurar as regras. Basta copiar e colar o arquivo abaixo e pressionar publicar. Essas regras garantem que as visualizações só podem ser incrementadas em 1, e as curtidas só podem ser alteradas em +1 ou -1 (e nunca abaixo de 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. Configurar Firestore - Selecione Build e abra Firestore. Crie uma nova base de dados e escolha iniciar no modo produção. Selecione a localização do servidor e aguarde. Assim que estiver iniciado, precisa de configurar as regras. Basta copiar e colar o ficheiro abaixo e premir publicar. 5. Configurar Firestore - Selecione Build e abra Firestore. Crie uma nova base de dados e escolha iniciar no modo produção. Selecione a localização do servidor e aguarde. Assim que estiver iniciado, precisa de configurar as regras. Basta copiar e colar o ficheiro abaixo e premir publicar. Estas regras garantem que as visualizações só podem ser incrementadas em 1, e os gostos só podem ser alterados em +1 ou -1 (e nunca abaixo de 0).
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

View File

@@ -40,13 +40,38 @@ const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app); const analytics = getAnalytics(app);
``` ```
5. 设置 Firestore - 选择 Build 并打开 Firestore. 创建一个数据库,并在生产环境中启动。选择服务器位置然后等待其部署完成。启动之后你需要配置规则。只需要复制并粘贴下面的内容,然后点击发布即可。 5. 设置 Firestore - 选择 Build 并打开 Firestore. 创建一个数据库,并在生产环境中启动。选择服务器位置然后等待其部署完成。启动之后你需要配置规则。只需要复制并粘贴下面的内容,然后点击发布即可。这些规则确保阅读量只能增加1点赞量只能增加或减少1且不会低于0
```txt ```txt
rules_version = '2'; rules_version = '2';
service cloud.firestore { service cloud.firestore {
match /databases/{database}/documents { match /databases/{database}/documents {
// Views - read anyone, only increment by 1
match /views/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['views'])
&& request.resource.data.views == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['views'])
&& request.resource.data.views == resource.data.views + 1;
}
// Likes - read anyone, only +1 or -1
match /likes/{document} {
allow read: if request.auth != null;
allow create: if request.auth != null
&& request.resource.data.keys().hasOnly(['likes'])
&& request.resource.data.likes == 1;
allow update: if request.auth != null
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['likes'])
&& (request.resource.data.likes == resource.data.likes + 1
|| request.resource.data.likes == resource.data.likes - 1)
&& request.resource.data.likes >= 0;
}
// Deny everything else
match /{document=**} { match /{document=**} {
allow read, write: if request.auth != null; allow read, write: if false;
} }
} }
} }

1979
scripts/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
scripts/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "blowfish-firebase-scripts",
"version": "1.0.0",
"description": "Scripts for seeding Firebase with Google Analytics data",
"type": "module",
"scripts": {
"seed-views": "node seed-firebase-views.js",
"seed-views:dry": "node seed-firebase-views.js --dry-run",
"seed-views:force": "node seed-firebase-views.js --force"
},
"dependencies": {
"csv-parse": "^5.5.0",
"firebase-admin": "^12.0.0"
}
}

View File

@@ -0,0 +1,322 @@
#!/usr/bin/env node
/**
* Import Google Analytics pageview data into Firestore
*
* Usage:
* 1. Export CSV from GA4: Reports → Engagement → Pages and screens → Download CSV
* 2. Get Firebase service account key from Firebase Console → Project Settings → Service accounts
* 3. Run: node seed-firebase-views.js <path-to-csv>
*
* The script maps GA page paths to Blowfish document IDs:
* /docs/configuration/ → views_docs-configuration-index.md
*/
import { initializeApp, cert } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
import { createReadStream, existsSync, readFileSync } from 'fs';
import { parse } from 'csv-parse';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Configuration
const SERVICE_ACCOUNT_PATH = join(__dirname, 'serviceAccountKey.json');
const COLLECTION_NAME = 'views';
const DRY_RUN = process.argv.includes('--dry-run');
const FORCE = process.argv.includes('--force'); // Overwrite existing documents
// Check for required files
if (!existsSync(SERVICE_ACCOUNT_PATH)) {
console.error('Error: serviceAccountKey.json not found in scripts directory');
console.error('Download it from Firebase Console → Project Settings → Service accounts');
process.exit(1);
}
const csvPath = process.argv.find(arg => !arg.startsWith('--') && !arg.includes('node') && !arg.includes('seed-firebase-views'));
if (!csvPath) {
console.error('Usage: node seed-firebase-views.js <path-to-csv> [options]');
console.error('');
console.error('Options:');
console.error(' --dry-run Preview what would be imported without writing to Firestore');
console.error(' --force Overwrite existing documents (useful for re-importing)');
process.exit(1);
}
if (!existsSync(csvPath)) {
console.error(`Error: CSV file not found: ${csvPath}`);
process.exit(1);
}
// Initialize Firebase Admin
const serviceAccount = JSON.parse(readFileSync(SERVICE_ACCOUNT_PATH, 'utf8'));
initializeApp({
credential: cert(serviceAccount)
});
const db = getFirestore();
// Language prefixes used in Hugo multilingual setup
// These get stripped from URLs to normalize to the base path
const LANGUAGE_PREFIXES = ['en', 'fr', 'zh-cn', 'es', 'de', 'it', 'ja', 'pt-pt', 'pt-br', 'bg', 'bn', 'fa', 'he', 'hu', 'id', 'pl', 'ro', 'ru', 'th', 'tr', 'uk', 'vi', 'zh-tw'];
/**
* Strip language prefix from a URL path
* /pt-pt/docs/configuration/ → /docs/configuration/
* /docs/configuration/ → /docs/configuration/ (unchanged)
*/
function stripLanguagePrefix(pagePath) {
for (const lang of LANGUAGE_PREFIXES) {
const prefix = `/${lang}/`;
if (pagePath.startsWith(prefix)) {
return '/' + pagePath.slice(prefix.length);
}
// Also handle case where lang is at root like /en or /pt-pt
if (pagePath === `/${lang}` || pagePath === `/${lang}/`) {
return '/';
}
}
return pagePath;
}
/**
* Convert a GA page path to a Blowfish Firestore document ID
*
* Blowfish uses .File.Path which includes "index.md" for page bundles:
* GA path: /docs/configuration/
* Hugo file: docs/configuration/index.md
* Firestore ID: views_docs-configuration-index.md
*/
function pathToDocId(pagePath) {
// First strip any language prefix
pagePath = stripLanguagePrefix(pagePath);
// Remove leading/trailing slashes
let cleanPath = pagePath.replace(/^\/|\/$/g, '');
// Handle special cases
if (cleanPath === '' || cleanPath === '/') {
// Homepage: _index.md
return 'views__index.md';
}
// For section pages like /docs/, the file is docs/_index.md
// For article pages like /docs/configuration/, the file is docs/configuration/index.md
// We can tell them apart: section pages don't have a second path segment after the section
const parts = cleanPath.split('/');
if (parts.length === 1) {
// Section page like "docs" → docs/_index.md
cleanPath = `${cleanPath}-_index.md`;
} else {
// Article page like "docs/configuration" → docs/configuration/index.md → docs-configuration-index.md
cleanPath = cleanPath.replace(/\//g, '-') + '-index.md';
}
// Replace any remaining slashes with hyphens
cleanPath = cleanPath.replace(/\//g, '-');
return `views_${cleanPath}`;
}
/**
* Parse the GA4 CSV export
* GA4 exports have metadata lines starting with # before the actual CSV data
* Aggregates views from different language URLs that map to the same document
*/
async function parseGACSV(filePath) {
// Use a Map to aggregate views by document ID
const viewsByDocId = new Map();
// Track original paths for debugging
const pathsByDocId = new Map();
return new Promise((resolve, reject) => {
createReadStream(filePath)
.pipe(parse({
columns: true,
skip_empty_lines: true,
trim: true,
relax_column_count: true,
comment: '#' // Skip lines starting with #
}))
.on('data', (row) => {
// GA4 uses various column names depending on the report
const pagePath = row['Page path'] ||
row['Page path and screen class'] ||
row['Page'] ||
row['Landing page'];
const views = row['Views'] ||
row['Pageviews'] ||
row['Sessions'] ||
row['Screen views'];
if (pagePath && views) {
// Clean up the path and parse views
const cleanPath = pagePath.trim();
const viewCount = parseInt(views.replace(/,/g, ''), 10);
// Only include valid paths (skip query strings, fragments, etc.)
if (cleanPath.startsWith('/') && !isNaN(viewCount) && viewCount > 0) {
// Strip language prefix to get normalized path
const normalizedPath = stripLanguagePrefix(cleanPath);
// Check if this is a page we want to import
// Include docs, samples, and other sections relevant to blowfish.page
const validPrefixes = ['/docs/', '/samples/', '/users/', '/contributors/', '/tags/', '/authors/'];
const isValidPath = normalizedPath === '/' ||
validPrefixes.some(p => normalizedPath.startsWith(p)) ||
validPrefixes.some(p => normalizedPath === p.slice(0, -1)); // /docs without trailing slash
if (isValidPath) {
const docId = pathToDocId(cleanPath);
// Take the MAX views (not sum) - handles duplicate URLs like with/without trailing slash
const existingViews = viewsByDocId.get(docId) || 0;
viewsByDocId.set(docId, Math.max(existingViews, viewCount));
// Track paths for debugging
const existingPaths = pathsByDocId.get(docId) || [];
existingPaths.push({ path: cleanPath, views: viewCount });
pathsByDocId.set(docId, existingPaths);
}
}
}
})
.on('end', () => {
// Convert Map to array
const results = [];
for (const [docId, totalViews] of viewsByDocId) {
const paths = pathsByDocId.get(docId);
results.push({
docId,
views: totalViews,
sourcePaths: paths // For debugging - shows which URLs were aggregated
});
}
// Sort by views descending
results.sort((a, b) => b.views - a.views);
resolve(results);
})
.on('error', reject);
});
}
/**
* Import data to Firestore
*/
async function importToFirestore(data) {
console.log(`\nImporting ${data.length} documents to Firestore...`);
if (FORCE) {
console.log('FORCE mode: will overwrite existing documents\n');
}
// Use batched writes for efficiency (max 500 per batch)
const batchSize = 500;
let imported = 0;
let updated = 0;
let skipped = 0;
for (let i = 0; i < data.length; i += batchSize) {
const batch = db.batch();
const chunk = data.slice(i, i + batchSize);
for (const item of chunk) {
const docRef = db.collection(COLLECTION_NAME).doc(item.docId);
// Check if document already exists
const existing = await docRef.get();
if (existing.exists) {
if (FORCE) {
batch.set(docRef, { views: item.views });
updated++;
} else {
console.log(` Skipping ${item.docId} (already exists with ${existing.data().views} views)`);
skipped++;
continue;
}
} else {
batch.set(docRef, { views: item.views });
imported++;
}
}
if (!DRY_RUN) {
await batch.commit();
}
console.log(` Processed ${Math.min(i + batchSize, data.length)}/${data.length}`);
}
return { imported, updated, skipped };
}
// Main execution
async function main() {
console.log('Google Analytics to Firestore Import');
console.log('====================================');
if (DRY_RUN) {
console.log('DRY RUN MODE - No changes will be made');
}
if (FORCE) {
console.log('FORCE MODE - Will overwrite existing documents');
}
console.log('');
console.log(`Reading CSV: ${csvPath}`);
const data = await parseGACSV(csvPath);
if (data.length === 0) {
console.error('\nNo valid data found in CSV. Check the file format.');
console.error('Expected columns: "Page path" (or similar) and "Views" (or similar)');
process.exit(1);
}
console.log(`\nFound ${data.length} unique pages to import:`);
console.log('');
// Show preview with aggregation info
const preview = data.slice(0, 15);
for (const item of preview) {
console.log(` ${item.docId}`);
console.log(` Views: ${item.views.toLocaleString()}`);
if (item.sourcePaths.length > 1) {
console.log(` (max from ${item.sourcePaths.length} URL variants):`);
for (const sp of item.sourcePaths.slice(0, 3)) {
console.log(` - ${sp.path}: ${sp.views.toLocaleString()}`);
}
if (item.sourcePaths.length > 3) {
console.log(` ... and ${item.sourcePaths.length - 3} more`);
}
}
}
if (data.length > 15) {
console.log(` ... and ${data.length - 15} more`);
}
// Show total views to verify parsing
const totalViews = data.reduce((sum, item) => sum + item.views, 0);
console.log(`\nTotal views across all pages: ${totalViews.toLocaleString()}`)
if (DRY_RUN) {
console.log('\n✓ Dry run complete. Run without --dry-run to import.');
return;
}
const { imported, updated, skipped } = await importToFirestore(data);
console.log('\n====================================');
console.log(`✓ Import complete!`);
console.log(` New documents: ${imported}`);
if (updated > 0) {
console.log(` Updated (--force): ${updated}`);
}
if (skipped > 0) {
console.log(` Skipped (already exist): ${skipped}`);
}
}
main().catch((error) => {
console.error('Error:', error.message);
process.exit(1);
});