UNPKG

ghost

Version:

The professional publishing platform

247 lines (217 loc) 8.81 kB
const logging = require('@tryghost/logging'); /** * @typedef {Object} TopContentDataItem * @property {string} pathname - Page path * @property {number} visits - Number of visits * @property {string} [post_uuid] - Associated post UUID if available * @property {string} [post_id] - Associated post ID if available * @property {string} [title] - Page title */ class ContentStatsService { /** * @param {object} deps * @param {import('knex').Knex} deps.knex - Database client * @param {object} deps.urlService - Ghost URL service for resource lookups * @param {object} [deps.tinybirdClient] - Configured Tinybird client */ constructor(deps) { this.knex = deps.knex; this.urlService = deps.urlService; this.tinybirdClient = deps.tinybirdClient; } /** * Fetches top pages data from Tinybird and enriches it with post titles * @param {Object} options * @param {string} [options.date_from] - Start date in YYYY-MM-DD format * @param {string} [options.date_to] - End date in YYYY-MM-DD format * @param {string} [options.timezone] - Timezone for the query * @param {string} [options.member_status] - Member status filter (defaults to 'all') * @param {string} [options.post_type] - Post type filter ('post' or 'page') * @param {string} [options.tb_version] - Tinybird version for API URL * @returns {Promise<Object>} The enriched top pages data */ async getTopContent(options = {}) { try { // Check if Tinybird client is available if (!this.tinybirdClient) { return {data: []}; } // Step 1: Get raw data from Tinybird const rawData = await this.fetchRawTopContentData(options); if (!rawData || !rawData.length) { return {data: []}; } // Step 2: Enrich the data with titles const enrichedData = await this.enrichTopContentData(rawData, options.post_type); return {data: enrichedData}; } catch (error) { logging.error('Error fetching top content:'); logging.error(error); return {data: []}; } } /** * Fetch raw top pages data from Tinybird * @param {Object} options - Query options with snake_case keys * @returns {Promise<Array<TopContentDataItem>|null>} Raw data or null on error */ async fetchRawTopContentData(options = {}) { // Convert snake_case to camelCase for Tinybird const tinybirdOptions = { dateFrom: options.date_from, dateTo: options.date_to, timezone: options.timezone, memberStatus: options.member_status, postType: options.post_type, tbVersion: options.tb_version }; return await this.tinybirdClient.fetch('api_top_pages', tinybirdOptions); } /** * Extract post UUIDs from page data (internal method) * @param {Array<TopContentDataItem>} data - Raw page data * @returns {Array<string>} Array of post UUIDs */ extractPostUuids(data) { return data .map((item) => { // Only use explicit post_uuid property return (item.post_uuid && item.post_uuid.trim() !== '') ? item.post_uuid : null; }) .filter(Boolean); } /** * Lookup post titles in the database * @param {Array<string>} uuids - Post UUIDs to look up * @param {string} [postType] - Optional post type filter ('post' or 'page') * @returns {Promise<Object>} Map of UUID to title */ async lookupPostTitles(uuids, postType = null) { if (!uuids.length) { return {}; } let query = this.knex.select('uuid', 'title', 'id', 'type') .from('posts') .whereIn('uuid', uuids); // Apply post type filter if specified if (postType) { query = query.where('type', postType); } const posts = await query; return posts.reduce((map, post) => { map[post.uuid] = { title: post.title, id: post.id, type: post.type }; return map; }, {}); } /** * Get resource title using UrlService * @param {string} pathname - Path to look up * @returns {Object|null} Resource title and type, or null if not found */ getResourceTitle(pathname) { if (!this.urlService) { return null; } try { const resource = this.urlService.getResource(pathname); if (resource && resource.data) { if (resource.data.title) { return { title: resource.data.title, resourceType: resource.data.type }; } else if (resource.data.name) { // For authors, tags, etc. return { title: resource.data.name, resourceType: resource.data.type }; } } } catch (err) { if (err.code !== 'URLSERVICE_NOT_READY') { logging.warn(`Error looking up resource for ${pathname}: ${err.message}`); } } return null; } /** * Enrich top pages data with titles and URL existence validation * @param {Array<TopContentDataItem>} data - Raw page data * @param {string} [postType] - Optional post type filter ('post' or 'page') * @returns {Promise<Array<TopContentDataItem>>} Enriched page data */ async enrichTopContentData(data, postType = null) { if (!data || !data.length) { return []; } // Extract post UUIDs and lookup titles const postUuids = this.extractPostUuids(data); const titleMap = await this.lookupPostTitles(postUuids, postType); // Enrich the data with post titles or UrlService lookups const enrichedData = await Promise.all(data.map(async (item) => { // Check URL existence using the URL service let urlExists = false; if (this.urlService && item.pathname) { try { // Check if URL service is ready if (this.urlService.hasFinished && this.urlService.hasFinished()) { const resource = this.urlService.getResource(item.pathname); urlExists = !!resource; // Convert to boolean } // If URL service isn't ready, we default to true (clickable) } catch (error) { // If there's an error checking the URL service, default to true urlExists = true; } } // Check if post_uuid is available directly if (item.post_uuid && titleMap[item.post_uuid]) { return { ...item, title: titleMap[item.post_uuid].title, post_id: titleMap[item.post_uuid].id, post_type: titleMap[item.post_uuid].type, url_exists: urlExists }; } // Use UrlService for pages without post_uuid const resourceInfo = this.getResourceTitle(item.pathname); if (resourceInfo) { return { ...item, title: resourceInfo.title, resourceType: resourceInfo.resourceType, post_type: null, url_exists: urlExists }; } // Otherwise fallback to pathname (removing leading/trailing slashes) const formattedPath = item.pathname.replace(/^\/|\/$/g, '') || 'Homepage'; return { ...item, title: formattedPath, post_type: null, url_exists: urlExists }; })); // Filter by post type if specified and the item has post_type information if (postType) { return enrichedData.filter((item) => { // Include items that have matching post_type if (item.post_type) { return item.post_type === postType; } // For items without post_type (like homepage, tags, etc.), // include them only if we're not filtering specifically for posts return postType !== 'post'; }); } return enrichedData; } } module.exports = ContentStatsService;