Skip to content

Commit 98f0f3f

Browse files
committed
Inline analytics data for admins
1 parent 61f72d2 commit 98f0f3f

11 files changed

+500
-554
lines changed

analytics.mjs

+221
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* Copyright 2018 Google Inc. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
'use strict';
18+
19+
import fs from 'fs';
20+
import url from 'url';
21+
const URL = url.URL;
22+
import GoogleAPIs from 'googleapis';
23+
const google = GoogleAPIs.google;
24+
// import GoogleAuth from 'google-auth-library';
25+
26+
let CACHE = new Map();
27+
28+
const START_DATE = '2011-01-01'; // Beginning date to fetch all analytics data for.
29+
const MIN_PAGEVIEWS = 10;
30+
31+
const VIEW_IDS = {
32+
robdodson: {
33+
viewId: '57149356',
34+
notPathRegexs: ['^/tag/', '^/blog/categories/', '^/author/rob/', '^/blog/page/', '^/page/'],
35+
},
36+
ericbidelman: {
37+
viewId: '48771992',
38+
pathRegexs: ['^/post/', '/post/14636214755'],
39+
removeFromTitles: ' - Eric Bidelman',
40+
},
41+
// webfu: {viewId: '88450368'},
42+
};
43+
44+
// const creds = JSON.parse(fs.readFileSync('./google_oauth_credentials.json'));
45+
// const API_KEY = 'AIzaSyCzhvPbNswHA1TXcqtSF0aiVj7O3oi9BfM';
46+
// const app = await google.auth.getApplicationDefault();
47+
48+
class Analytics {
49+
static get SCOPES() {
50+
return ['https://www.googleapis.com/auth/analytics.readonly'];
51+
}
52+
53+
constructor(authClient) {
54+
this.api = google.analyticsreporting({
55+
version: 'v4',
56+
auth: authClient, // API_KEY,
57+
});
58+
}
59+
60+
/**
61+
* Creates a new map with titles mapped to results.
62+
* @param {!Map} map
63+
*/
64+
static toTitleMap(map) {
65+
const titleMap = new Map();
66+
for (const [path, result] of map) {
67+
titleMap.set(result.title, result);
68+
}
69+
return titleMap;
70+
}
71+
72+
/**
73+
* Fetch results from the Reporting API.
74+
* @param {!Object=} query
75+
* @return {!{report: !Object, headers: !Array<{type: string, name: string}>}}
76+
*/
77+
async query({viewId, startDate = '30daysAgo', endDate = 'yesterday',
78+
pathRegexs = ['/'], notPathRegexs = []} = {}) {
79+
const query = {
80+
viewId,
81+
dateRanges: [{startDate, endDate}],
82+
metrics: [{expression: 'ga:pageviews'}, {expression: 'ga:users'}],
83+
metricFilterClauses: [{
84+
filters: [{
85+
metricName: 'ga:pageviews',
86+
operator: 'GREATER_THAN',
87+
comparisonValue: String(MIN_PAGEVIEWS),
88+
}]
89+
}],
90+
dimensions: [{name: 'ga:pageTitle'}, {name: 'ga:pagePath'}],
91+
orderBys: [{fieldName: 'ga:pageviews', sortOrder: 'DESCENDING'}],
92+
dimensionFilterClauses: [{
93+
operator: 'AND',
94+
filters: [],
95+
}],
96+
};
97+
98+
// Filter by user-provided path regexs.
99+
pathRegexs.forEach(regex => query.dimensionFilterClauses[0].filters.push({
100+
dimensionName: 'ga:pagePath',
101+
operator: 'REGEXP',
102+
expressions: pathRegexs,
103+
}));
104+
105+
// Filter out some additional paths.
106+
notPathRegexs.forEach(regex => query.dimensionFilterClauses[0].filters.push({
107+
dimensionName: 'ga:pagePath',
108+
not: true,
109+
operator: 'REGEXP',
110+
expressions: [regex],
111+
}));
112+
113+
const resp = await this.api.reports.batchGet({
114+
resource: {reportRequests: [query]}
115+
});
116+
117+
const report = resp.data.reports[0];
118+
const headers = report.columnHeader.metricHeader.metricHeaderEntries;
119+
const urlMap = new Map();
120+
121+
if (!report.data.rowCount) {
122+
return {results: urlMap, headers, startDate, endDate, rowCount: 0};
123+
}
124+
125+
report.data.rows.forEach((row, i) => {
126+
row.metrics.forEach((metric, j) => {
127+
const [title, path] = row.dimensions;
128+
const [pageviews, users] = metric.values.map(val => parseInt(val));
129+
130+
// If a URL has already been seen, add to its pageviews. Ignore query params.
131+
const pathWithoutParams = new URL(path, 'http://dummydomain.com').pathname;
132+
const item = urlMap.get(pathWithoutParams);
133+
if (item) {
134+
item.pageviews += pageviews;
135+
item.users += users;
136+
} else {
137+
urlMap.set(pathWithoutParams, {title, path: pathWithoutParams, pageviews, users});
138+
}
139+
});
140+
});
141+
142+
return {
143+
results: urlMap,
144+
headers,
145+
startDate,
146+
endDate,
147+
rowCount: report.data.rowCount
148+
};
149+
}
150+
}
151+
152+
let authClient = google.auth.fromJSON(JSON.parse(
153+
fs.readFileSync('./analyticsServiceAccountKey.json')));
154+
// authClient.scopes = Analytics.SCOPES;
155+
if (authClient.createScopedRequired && authClient.createScopedRequired()) {
156+
authClient = authClient.createScoped(Analytics.SCOPES);
157+
}
158+
159+
/**
160+
* @param {boolean=} clearCache Whether to clear the cache. True by default.
161+
* @return {Promise<!Map>}
162+
*/
163+
async function updateAnalyticsData(clearCache = false) {
164+
if (CACHE.size && !clearCache) {
165+
return CACHE;
166+
}
167+
168+
console.info('Updating Analytics data...');
169+
const tic = Date.now();
170+
171+
await authClient.authorize();
172+
173+
const ga = new Analytics(authClient);
174+
175+
const merged = new Map();
176+
177+
for (const [user, config] of Object.entries(VIEW_IDS)) {
178+
const result = await ga.query({
179+
viewId: config.viewId,
180+
startDate: START_DATE,
181+
endDate: (new Date()).toJSON().split('T')[0],
182+
pathRegexs: config.pathRegexs,
183+
notPathRegexs: config.notPathRegexs,
184+
});
185+
result.results.forEach(item => {
186+
if ('removeFromTitles' in config) {
187+
item.title = item.title.replace(config.removeFromTitles, '');
188+
}
189+
merged.set(item.path, item);
190+
});
191+
}
192+
193+
// Sort by pageviews.
194+
const results = new Map([...merged.entries()]
195+
.sort((a, b) => b[1].pageviews - a[1].pageviews));
196+
197+
CACHE = results;
198+
199+
console.info(`Analytics update took ${(Date.now() - tic)/1000}s`);
200+
201+
return results;
202+
}
203+
204+
export {Analytics, updateAnalyticsData};
205+
206+
// (async() => {
207+
208+
// // const oauth2Client = new google.auth.OAuth2();//creds.client_id, creds.client_secret, '');
209+
// // oauth2Client.setCredentials(creds);
210+
// // oauth2Client.apiKey = API_KEY;
211+
// // google.options({auth: oauth2Client});
212+
213+
// // const creds = await GoogleAuth.auth.getCredentials();
214+
// const allResults = await updateAnalyticsData();
215+
// let i = 1;
216+
// for (const [path, result] of allResults) {
217+
// const {title, path, pageviews, users} = result;
218+
// console.log(`${i++}. ${path} ${formatNumber(pageviews)} views, ${formatNumber(users)} users`);
219+
// }
220+
221+
// })();

package.json

+4-12
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,19 @@
1010
"scripts": {
1111
"start": "node --experimental-modules server.mjs",
1212
"build": "docker build -t team-contributions .",
13-
"build-app": "babel --plugins transform-es2015-modules-commonjs -o ./public/app.build.js ./public/app.js",
1413
"bundle": "rollup -c",
1514
"clean": "rm -rf .nyc_output/ coverage/",
1615
"coverage": "URL=http://localhost:8080/ node --experimental-modules scripts/codecoverage.mjs && ./node_modules/nyc/bin/nyc.js report --reporter=html",
1716
"docker": "docker kill team-contributions; yarn build && docker run -dit -p 8080:8080 --rm --name team-contributions --cap-add=SYS_ADMIN team-contributions",
1817
"deploy": "npm run bundle && gcloud app deploy app.yaml --project devwebfeed",
19-
"deploy:cron": "gcloud app deploy cron.yaml --project devwebfeed",
20-
"dashboard": "npx webdash serve"
18+
"deploy:cron": "gcloud app deploy cron.yaml --project devwebfeed"
2119
},
2220
"dependencies": {
2321
"body-parser": "^1.18.2",
2422
"express": "^4.16.3",
25-
"firebase": "^4.12.0",
23+
"firebase": "^4.12.1",
2624
"firebase-admin": "^5.11.0",
25+
"googleapis": "^28.1.0",
2726
"lit-html": "^0.9.0",
2827
"puppeteer": "^1.2.0",
2928
"rss": "^1.2.2",
@@ -32,8 +31,6 @@
3231
"universal-analytics": "^0.4.16"
3332
},
3433
"devDependencies": {
35-
"babel-cli": "^6.26.0",
36-
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
3734
"cssnano": "^3.10.0",
3835
"nyc": "^11.6.0",
3936
"postcss": "^6.0.20",
@@ -42,11 +39,6 @@
4239
"rollup": "^0.57.1",
4340
"rollup-plugin-filesize": "^1.5.0",
4441
"rollup-plugin-includepaths": "^0.2.2",
45-
"rollup-plugin-uglify": "^3.0.0",
46-
"webdash": "^1.0.0",
47-
"webdash-npm-scripts": "^1.0.0",
48-
"webdash-package-json": "^1.0.0",
49-
"webdash-performance-budget": "^1.2.0",
50-
"webdash-pwa-manifest": "^1.1.0"
42+
"rollup-plugin-uglify": "^3.0.0"
5143
}
5244
}

public/app.js

+32-27
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async function fetchPosts(url, maxResults = null) {
3131
if (maxResults) {
3232
url.searchParams.set('maxresults', maxResults);
3333
}
34-
const resp = await fetch(url.toString());
34+
const resp = await fetch(url.href);
3535
const json = await resp.json();
3636
if (!resp.ok || json.error) {
3737
throw Error(json.error);
@@ -194,11 +194,13 @@ function sharePost(el, url, title) {
194194
return false;
195195
}
196196

197-
async function getPosts(forYear, includeTweets = false) {
198-
// const lastYearsPosts = await fetchPosts(`/posts/${util.currentYear - 1}`);
199-
const thisYearsPosts = await fetchPosts(`/posts/${forYear}`);
197+
async function getPosts(forYear, includeTweets = false, uid = null) {
198+
const url = new URL(`/posts/${forYear}`, location);
199+
if (uid) {
200+
url.searchParams.set('uid', uid);
201+
}
202+
const thisYearsPosts = await fetchPosts(url.href);
200203

201-
// const posts = util.uniquePosts([...thisYearsPosts, ...lastYearsPosts, ...tweets]);
202204
const posts = thisYearsPosts;
203205
if (includeTweets) {
204206
const tweets = await fetchPosts(`/tweets/ChromiumDev`);
@@ -217,6 +219,7 @@ async function initAuth() {
217219
if (token) {
218220
const login = document.querySelector('#login');
219221
const email = login.querySelector('.login-email');
222+
220223
email.addEventListener('click', async e => {
221224
e.preventDefault();
222225

@@ -228,7 +231,8 @@ async function initAuth() {
228231
login.hidden = true;
229232
});
230233

231-
email.textContent = token.email;
234+
const admin = await auth.isAdmin(true);
235+
email.textContent = token.email + (admin ? ' (admin)' : '');
232236
login.hidden = false;
233237
}
234238
}
@@ -240,30 +244,31 @@ document.body.classList.toggle('supports-share', !!navigator.share);
240244
const PRE_RENDERED = container.querySelector('#posts'); // Already exists in DOM if we've SSR.
241245

242246
const params = new URL(location.href).searchParams;
247+
const adminMode = params.has('edit');
248+
const year = params.get('year') || util.currentYear;
249+
const includeTweets = params.has('tweets');
250+
251+
// Logged in user stuff.
252+
let uid = null;
253+
if (adminMode) {
254+
await initAuth();
255+
uid = auth.getUid();
256+
container.classList.add('edit');
257+
}
243258

244-
try {
245-
// Populates client-side cache for future realtime updates.
246-
// Note: this basically results in 2x requests per page load, as we're
247-
// making the same requests the server just made. Now repeating them client-side.
248-
_posts = await getPosts(params.get('year') || util.currentYear, params.has('tweets'));
249-
250-
// Posts markup is already in place if we're SSRing. Don't re-render DOM.
251-
if (!PRE_RENDERED) {
252-
renderPosts(_posts, container);
253-
}
259+
// Populates client-side cache for future realtime updates.
260+
_posts = await getPosts(year, includeTweets, uid);
261+
262+
// Posts markup is already in place if we're SSRing. Don't re-render DOM.
263+
if (!PRE_RENDERED) {
264+
renderPosts(_posts, container);
265+
}
254266

255-
realtimeUpdatePosts(util.currentYear); // Subscribe to realtime firestore updates for current year.
267+
realtimeUpdatePosts(util.currentYear); // Subscribe to realtime firestore updates for current year.
256268

257-
if (params.has('edit')) {
258-
container.classList.add('edit');
259-
await initAuth();
260-
} else {
261-
for (const key of params.keys()) {
262-
filterBy(key, params.get(key));
263-
}
264-
}
265-
} catch (err) {
266-
console.error(err);
269+
// Filter list after data has been set.
270+
for (const key of params.keys()) {
271+
filterBy(key, params.get(key));
267272
}
268273
})();
269274

0 commit comments

Comments
 (0)