| | |
| | |
| | |
| | |
| |
|
| | import fs from 'fs' |
| | import path from 'path' |
| | import { Command } from 'commander' |
| | import chalk from 'chalk' |
| | import ora from 'ora' |
| | import frontmatter from '@/frame/lib/read-frontmatter' |
| | import { getKustoClient } from '@/metrics/lib/kusto-client' |
| | import { getDates, type DateRange } from '@/metrics/lib/dates' |
| | import { getViews } from '@/metrics/queries/views' |
| | import { getUsers } from '@/metrics/queries/users' |
| | import { getViewDuration } from '@/metrics/queries/view-duration' |
| | import { getBounces } from '@/metrics/queries/bounces' |
| | import { getScore } from '@/metrics/queries/survey-score' |
| | import { getExitsToSupport } from '@/metrics/queries/exits-to-support' |
| |
|
| | const { green, white, red, blue } = chalk |
| |
|
| | const DOCS_ROOT = 'https://docs.github.com' |
| | const DOCS_API_PATH = 'https://docs.github.com/api/pagelist/en' |
| | const FREE_PRO_TEAM = 'free-pro-team@latest' |
| | const ENTERPRISE_REGEX = /enterprise-(server|cloud)@/ |
| |
|
| | interface CliOptions { |
| | range?: string |
| | compare?: boolean |
| | views?: boolean |
| | users?: boolean |
| | viewDuration?: boolean |
| | bounces?: boolean |
| | score?: boolean |
| | exits?: boolean |
| | json?: boolean |
| | skipValidation?: boolean |
| | redirects?: boolean |
| | fptOnly?: boolean |
| | verbose?: boolean |
| | defaultToAll?: boolean |
| | showDocset?: boolean |
| | allVersions?: boolean |
| | } |
| |
|
| | interface JsonOutput { |
| | daysRange: string |
| | startDate: string |
| | endDate: string |
| | dateRange: string |
| | inputUrl: string |
| | data: { |
| | path: string |
| | views?: string |
| | users?: string |
| | viewDuration?: string |
| | bounces?: string |
| | score?: string |
| | exits?: string |
| | } |
| | docset?: { |
| | path: string |
| | data: { |
| | views?: string |
| | users?: string |
| | viewDuration?: string |
| | bounces?: string |
| | score?: string |
| | exits?: string |
| | } |
| | } |
| | } |
| |
|
| | const program = new Command() |
| |
|
| | program |
| | .name('docstat') |
| | .description( |
| | `Get a data snapshot of a given Docs URL for the last 30 days or specified period. By default, it looks up: |
| | |
| | - Views |
| | - Users |
| | - View duration per day average |
| | - Bounces |
| | - Helpfulness score |
| | - Exits to support`, |
| | ) |
| | .argument('<url>', 'URL to query data for') |
| | .option('-r, --range <days>', 'Number of days to look back', '30') |
| | .option('-c, --compare', 'Compare with top-level docset data') |
| | .option('-v, --views', 'Get page views') |
| | .option('-u, --users', 'Get unique users') |
| | .option('-d, --viewDuration', 'Get view duration per day average') |
| | .option('-b, --bounces', 'Get bounces') |
| | .option('-s, --score', 'Get helpfulness survey score') |
| | .option('-e, --exits', 'Get exits to support percentage') |
| | .option('-j, --json', 'Output results in JSON format') |
| | .option('-k, --skipValidation', 'Skip path validation against Docs API pagelist') |
| | .option('--redirects', 'Include all redirected URLs for the given URL in the queries') |
| | .option( |
| | '--fptOnly', |
| | 'Get data for free-pro-team@latest only (default: all versions if URL is versionless)', |
| | ) |
| | .option('--verbose', 'Display Kusto queries being executed') |
| | .parse(process.argv) |
| |
|
| | const options = program.opts<CliOptions>() |
| |
|
| | |
| | options.defaultToAll = !( |
| | options.views || |
| | options.users || |
| | options.viewDuration || |
| | options.bounces || |
| | options.score || |
| | options.exits |
| | ) |
| |
|
| | |
| | if (options.defaultToAll) { |
| | options.views = true |
| | options.users = true |
| | options.viewDuration = true |
| | options.bounces = true |
| | options.score = true |
| | options.exits = true |
| | } |
| |
|
| | |
| | |
| | |
| | const providedPath = program.args[0] |
| | let cleanPath = getCleanPath(providedPath) |
| |
|
| | |
| | let version: string | null = getVersion(cleanPath) |
| | const usingFptOnly = !!options.fptOnly |
| |
|
| | |
| | if (version === FREE_PRO_TEAM) { |
| | if (usingFptOnly) { |
| | |
| | console.log( |
| | '\nFetching data for free-pro-team@latest only. To get all versions, omit the --fptOnly flag.\n', |
| | ) |
| | } else { |
| | |
| | version = null |
| | console.log( |
| | '\nFetching data for all versions (no version specified in URL). To get only free-pro-team@latest, pass "--fptOnly".\n', |
| | ) |
| | } |
| | } else { |
| | |
| | console.log( |
| | `\nFetching data for version "${version}" as specified in the URL. To get data for all versions, remove the version segment from the URL.\n`, |
| | ) |
| | if (usingFptOnly) { |
| | console.log( |
| | `You specified a version in the URL (${version}), but also passed --fptOnly. Only the version in the URL will be used.\n`, |
| | ) |
| | } |
| | |
| | } |
| |
|
| | |
| | const VERSIONED_DOCS_API_PATH = path.join(DOCS_API_PATH, version || FREE_PRO_TEAM) |
| | |
| | cleanPath = removeVersionSegment(cleanPath, version || FREE_PRO_TEAM) |
| | |
| | if (!options.skipValidation) await validatePath(cleanPath, version || FREE_PRO_TEAM) |
| |
|
| | if (options.allVersions) version = null |
| |
|
| | |
| | const docsetPath = cleanPath.split('/')[0] |
| |
|
| | |
| | let redirects: string[] = [] |
| | if (options.redirects) { |
| | let contentPath = path.join('content', cleanPath) |
| | contentPath = fs.existsSync(contentPath) |
| | ? path.join(contentPath, 'index.md') |
| | : `${contentPath}.md` |
| | const { data } = frontmatter(fs.readFileSync(contentPath, 'utf8')) |
| | |
| | redirects = (data?.redirect_from || []).map((oldPath: string) => oldPath.replace('/', '')) |
| | } |
| |
|
| | const queryPaths = [cleanPath].concat(redirects) |
| |
|
| | |
| | const dates: DateRange = getDates(options.range) |
| |
|
| | async function main(): Promise<void> { |
| | const spinner = ora('Connecting to Kusto...').start() |
| |
|
| | try { |
| | const client = getKustoClient() |
| |
|
| | if (!client) { |
| | spinner.fail('Failed to connect to Kusto') |
| | process.exit(1) |
| | } |
| |
|
| | spinner.text = 'Connected! Querying Kusto...' |
| |
|
| | |
| | options.showDocset = !(cleanPath === docsetPath) && options.compare |
| | if (options.compare && cleanPath === docsetPath) { |
| | console.log(`\n\nSkipping comparison, since '${cleanPath}' is already a docset.\n`) |
| | } |
| |
|
| | |
| | const [ |
| | views, |
| | viewsDocset, |
| | users, |
| | usersDocset, |
| | viewDuration, |
| | viewDurationDocset, |
| | bounces, |
| | bouncesDocset, |
| | score, |
| | scoreDocset, |
| | exits, |
| | exitsDocset, |
| | ] = await Promise.all([ |
| | options.views |
| | ? getViews(queryPaths, client, dates, version, options.verbose, 'views') |
| | : undefined, |
| | options.views && options.showDocset |
| | ? getViews(docsetPath, client, dates, version, options.verbose, 'docset views') |
| | : undefined, |
| | options.users |
| | ? getUsers(queryPaths, client, dates, version, options.verbose, 'users') |
| | : undefined, |
| | options.users && options.showDocset |
| | ? getUsers(docsetPath, client, dates, version, options.verbose, 'docset users') |
| | : undefined, |
| | options.viewDuration |
| | ? getViewDuration(queryPaths, client, dates, version, options.verbose, 'view duration') |
| | : undefined, |
| | options.viewDuration && options.showDocset |
| | ? getViewDuration( |
| | docsetPath, |
| | client, |
| | dates, |
| | version, |
| | options.verbose, |
| | 'docset view duration', |
| | ) |
| | : undefined, |
| | options.bounces |
| | ? getBounces(queryPaths, client, dates, version, options.verbose, 'bounces') |
| | : undefined, |
| | options.bounces && options.showDocset |
| | ? getBounces(docsetPath, client, dates, version, options.verbose, 'docset bounces') |
| | : undefined, |
| | options.score |
| | ? getScore(queryPaths, client, dates, version, options.verbose, 'score') |
| | : undefined, |
| | options.score && options.showDocset |
| | ? getScore(docsetPath, client, dates, version, options.verbose, 'docset score') |
| | : undefined, |
| | options.exits |
| | ? getExitsToSupport(queryPaths, client, dates, version, options.verbose, 'exits') |
| | : undefined, |
| | options.exits && options.showDocset |
| | ? getExitsToSupport(docsetPath, client, dates, version, options.verbose, 'docset exits') |
| | : undefined, |
| | ]) |
| |
|
| | spinner.succeed('Data retrieved successfully!\n') |
| |
|
| | |
| | if (options.json) { |
| | const jsonOutput: JsonOutput = { |
| | daysRange: options.range || '30', |
| | startDate: dates.startDate, |
| | endDate: dates.endDate, |
| | dateRange: dates.friendlyRange, |
| | inputUrl: program.args[0], |
| | data: { |
| | path: cleanPath, |
| | }, |
| | } |
| |
|
| | |
| | if (options.views) { |
| | jsonOutput.data.views = views |
| | } |
| | if (options.users) { |
| | jsonOutput.data.users = users |
| | } |
| | if (options.viewDuration) { |
| | jsonOutput.data.viewDuration = viewDuration |
| | } |
| | if (options.bounces) { |
| | jsonOutput.data.bounces = bounces |
| | } |
| | if (options.score) { |
| | jsonOutput.data.score = score |
| | } |
| | if (options.exits) { |
| | jsonOutput.data.exits = exits |
| | } |
| |
|
| | |
| | if (options.showDocset) { |
| | jsonOutput.docset = { |
| | path: docsetPath, |
| | data: {}, |
| | } |
| |
|
| | if (options.views) { |
| | jsonOutput.docset.data.views = viewsDocset |
| | } |
| | if (options.users) { |
| | jsonOutput.docset.data.users = usersDocset |
| | } |
| | if (options.viewDuration) { |
| | jsonOutput.docset.data.viewDuration = viewDurationDocset |
| | } |
| | if (options.bounces) { |
| | jsonOutput.docset.data.bounces = bouncesDocset |
| | } |
| | if (options.score) { |
| | jsonOutput.docset.data.score = scoreDocset |
| | } |
| | if (options.exits) { |
| | jsonOutput.docset.data.exits = exitsDocset |
| | } |
| | } |
| |
|
| | console.log(JSON.stringify(jsonOutput, null, 2)) |
| | return |
| | } |
| |
|
| | console.log(white(`Last ${options.range || '30'} days:`), blue(dates.friendlyRange)) |
| | console.log(green('-------------------------------------------')) |
| | console.log(green('Path:'), white(cleanPath)) |
| | if (options.redirects) { |
| | console.log( |
| | green('Redirects included:'), |
| | white(redirects.length ? redirects.join(', ') : 'none found'), |
| | ) |
| | } |
| | console.log(green('Version:'), white(version || 'all versions')) |
| | console.log('') |
| |
|
| | if (options.views) { |
| | console.log(green('Views:'), white(views)) |
| | } |
| | if (options.users) { |
| | console.log(green('Users:'), white(users)) |
| | } |
| | if (options.viewDuration) { |
| | console.log(green('View duration per day average:'), white(viewDuration)) |
| | } |
| | if (options.bounces) { |
| | console.log(green('Bounces:'), white(`${bounces}`)) |
| | } |
| | if (options.score) { |
| | console.log(green('Score:'), white(`${score}`)) |
| | } |
| | if (options.exits) { |
| | console.log(green(`Exits to support:`), white(`${exits}`)) |
| | } |
| |
|
| | if (options.showDocset) { |
| | console.log('') |
| | console.log(white('Comparing to...')) |
| | console.log(green('-------------------------------------------')) |
| | console.log(green('Docset:'), white(docsetPath)) |
| | console.log(green('Version:'), white(version || 'all versions')) |
| | console.log('') |
| |
|
| | if (options.views) { |
| | console.log(green('Views:'), white(viewsDocset)) |
| | } |
| | if (options.users) { |
| | console.log(green('Users:'), white(usersDocset)) |
| | } |
| | if (options.viewDuration) { |
| | console.log(green(`View duration per day average:`), white(`${viewDurationDocset}`)) |
| | } |
| | if (options.bounces) { |
| | console.log(green(`Bounces:`), white(`${bouncesDocset}`)) |
| | } |
| | if (options.score) { |
| | console.log(green(`Score:`), white(`${scoreDocset}`)) |
| | } |
| | if (options.exits) { |
| | console.log(green(`Exits to support:`), white(`${exitsDocset}`)) |
| | } |
| | } |
| |
|
| | console.log(green('-------------------------------------------')) |
| | } catch (error) { |
| | spinner.fail('Error getting data') |
| | console.error(red('Error details:')) |
| | console.error(error) |
| | } |
| | } |
| |
|
| | try { |
| | await main() |
| | } catch (error) { |
| | console.error(red('Unexpected error:')) |
| | console.error(error) |
| | process.exit(1) |
| | } |
| |
|
| | |
| |
|
| | |
| | |
| | function getCleanPath(inputPath: string): string { |
| | let clean = inputPath |
| | const cleanArr = clean.split('?') |
| | if (cleanArr.length > 1) cleanArr.pop() |
| | clean = cleanArr.join('/') |
| | const cleanArr2 = clean.split('#') |
| | if (cleanArr2.length > 1) cleanArr2.pop() |
| | clean = cleanArr2.join('/') |
| | if (clean === DOCS_ROOT || clean === `${DOCS_ROOT}/en`) { |
| | |
| | return 'index' |
| | } |
| | const pathParts = clean.replace(DOCS_ROOT, '').split('/').filter(Boolean) |
| | if (pathParts[0] === 'en') pathParts.shift() |
| | clean = pathParts.join('/') |
| |
|
| | return clean |
| | } |
| |
|
| | function getVersion(pathToCheck: string): string { |
| | const pathParts = pathToCheck.split('/') |
| | const versionString = ENTERPRISE_REGEX.test(pathParts[0]) ? pathParts[0] : FREE_PRO_TEAM |
| | return versionString |
| | } |
| |
|
| | function removeVersionSegment(pathToProcess: string, versionString: string): string { |
| | if (versionString === FREE_PRO_TEAM) return pathToProcess |
| | const pathParts = pathToProcess.split('/') |
| | pathParts.shift() |
| | if (!pathParts.length) return 'index' |
| | return pathParts.join('/') |
| | } |
| |
|
| | |
| | async function validatePath(pathToValidate: string, versionToValidate: string): Promise<void> { |
| | |
| | const basePath = pathToValidate === 'index' ? '' : pathToValidate |
| |
|
| | const pathToCheck = |
| | versionToValidate === FREE_PRO_TEAM |
| | ? path.join('/', 'en', basePath) |
| | : path.join('/', 'en', versionToValidate, basePath) |
| |
|
| | let data: string |
| | try { |
| | const response = await fetch(VERSIONED_DOCS_API_PATH) |
| | data = await response.text() |
| | } catch (err) { |
| | console.error(`Error fetching data from ${VERSIONED_DOCS_API_PATH}`) |
| | throw err |
| | } |
| |
|
| | if (data.startsWith('{')) { |
| | const parsedData = JSON.parse(data) |
| | if (parsedData.error) { |
| | console.error(data) |
| | process.exit(1) |
| | } |
| | } |
| |
|
| | const isValid = data.includes(pathToCheck) |
| | if (!isValid) { |
| | console.error( |
| | `Error! Provided URL is not in Docs API list of valid paths at ${VERSIONED_DOCS_API_PATH}`, |
| | ) |
| | process.exit(1) |
| | } |
| | } |
| |
|