diff --git a/src/background.js b/src/background.js deleted file mode 100644 index 823bd37..0000000 --- a/src/background.js +++ /dev/null @@ -1,137 +0,0 @@ -/* - * ================================================================================================= - * CONSTANTS - * ================================================================================================= - */ - -const CONTENT_SCRIPT = '/content-script.js'; -const FEED_BASE_URL = 'https://www.youtube.com/feeds/videos.xml?'; -const YOUTUBE_VIDEO_WATCHING = 'https://www.youtube.com/watch?'; - -/* - * ================================================================================================= - * CLASSES - * ================================================================================================= - */ - -/** - * A class containing various utility functions. - */ -class Utils { - /** - * Check if the provided URL is valid (HTTP-based URL). - * @param {string} urlString The URL to check - * @returns {boolean} `true` if the URL is supported, `false` otherwise - */ - static isValidURL(urlString) { - const supportedProtocols = ['https:', 'http:']; - const url = new URL(urlString); - return supportedProtocols.indexOf(url.protocol) !== -1; - } - - /** - * Check if the provided URL is corresponding to a video being watched on YouTube - * @param {string} url The URL to check - * @returns {boolean} `true` if the URL is one of a video being watched, `false` otherwise - */ - static isVideoWatchingURL(url) { - return url.substring(0, 30) === YOUTUBE_VIDEO_WATCHING; - } - - /** - * Build the channel RSS feed unique identifier. - * @param {string} url The YouTube channel URL - * @param {string} splitter Divider used to break down the channel URL - * @param {string} queryStringParameter The parameter to indicate the type of content to retrieve - * @returns {(string|null)} The identifier of the channel or `null` in case of error - */ - static buildFeedIdentifier(url, splitter, queryStringParameter) { - let channel; - const test = url.split(splitter)[1]; - if (test) { - const id = test.split('/')[0]; - if (id && typeof id !== 'undefined') { - // Remove query string parameters - const sanitizedId = id.split('?')[0]; - channel = `${queryStringParameter}=${sanitizedId}`; - } - } - return channel || null; - } - - /** - * Build the URL of the RSS feed of a YouTube channel. - * @param {string} url The YouTube channel URL - * @returns {(string|null)} The URL of the channel RSS feed or `null` in case of error - */ - static buildChannelFeed(url) { - let identifier = null; - if (url.split('channel/')[1]) { - identifier = Utils.buildFeedIdentifier(url, 'channel/', 'channel_id'); - } else if (url.split('user/')[1]) { - identifier = Utils.buildFeedIdentifier(url, 'user/', 'user'); - } else if (url.split('/').length === 4) { - identifier = Utils.buildFeedIdentifier(url, 'youtube.com/', 'user'); - } - if (identifier !== null) return FEED_BASE_URL + identifier; - return null; - } -} - -/* - * ================================================================================================= - * EXTENSION LOGIC - * ================================================================================================= - */ - -/** - * Retrieve the RSS feed of the channel when watching a video of said channel. - * Executes content script to retrieve the channel URL from the DOM. - * @returns {(string|null)} The URL of the channel RSS feed or `null` in case of error - */ -async function getFeedFromDOM() { - const result = await browser.tabs.executeScript({ - file: CONTENT_SCRIPT, - }); - return Utils.buildChannelFeed(result[0]); -} - -/** - * Callback function executed when the page action is clicked. - * Retrieves the YouTube channel RSS feed and opens it in a new tab. - * @param {Tab} tab The tab whose page action was clicked - * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab `Tab` type definition} - */ -async function onPageActionClick(tab) { - let feed = null; - if (Utils.isVideoWatchingURL(tab.url)) { - feed = await getFeedFromDOM(); - } else { - feed = await Utils.buildChannelFeed(tab.url); - } - if (Utils.isValidURL(feed)) { - browser.tabs.create({ - url: feed, - active: true, - }); - } else { - browser.notifications.create('', { - type: 'basic', - title: browser.runtime.getManifest().name, - message: browser.i18n.getMessage('feedRetrievalError'), - iconUrl: browser.extension.getURL('icons/error/error-96.png'), - }); - } -} - -/* - * ================================================================================================= - * LISTENERS - * ================================================================================================= - */ - -// ------------------------------------------------------------------------------------------------- -// PAGE ACTION -// ------------------------------------------------------------------------------------------------- -// Listen for clicks on the page action (icon in the address bar) -browser.pageAction.onClicked.addListener(onPageActionClick); diff --git a/src/background/ChannelFeedBuilder.js b/src/background/ChannelFeedBuilder.js new file mode 100644 index 0000000..bb103a5 --- /dev/null +++ b/src/background/ChannelFeedBuilder.js @@ -0,0 +1,115 @@ +/** + * A class containing the logic to build the RSS feed of a YouTube channel from a given page. + * For more information regarding channel addresses, please see the links below. + * @see {@link https://support.google.com/youtube/answer/6180214 Understand your channel URLs} + * @see {@link https://support.google.com/youtube/answer/2657968 Get a custom URL for your channel} + */ +class ChannelFeedBuilder { + /** + * Create a channel feed builder for the given page. + * @param {number} tabID The ID of the current tab + * @param {URL} url The URL object representing the current page + */ + constructor(tabId, url) { + /** @type {number} The ID of the current tab */ + this.tabId = tabId; + /** @type {URL} The URL of the current page */ + this.url = url; + /** @type {?URL} The URL of the channel */ + this.channelAddress = null; + /** @type {?string} The unique identifier of the channel (legacy user ID or channel ID) */ + this.channelIdentifier = null; + } + + /** + * Execute a content script and return the result as an URL object. + * @param {string} file Path to the content script file to be executed + * @returns {?URL} URL object created from the content script result or `null` in case of error + */ + static async executeContentScript(file) { + const scriptResults = await browser.tabs.executeScript(this.tabId, { + file, + runAt: 'document_idle', + }); + return Utils.buildUrlObject(scriptResults[0]); + } + + /** + * Get the address of the channel of the current page from the DOM. + * The address channel can be based on the legacy user ID or the channel ID. + * @returns {?URL} URL object representing the channel address or `null` in case of error + */ + static async getChannelAddressfromDOM() { + Utils.debug('Requesting the channel address from the DOM'); + return ChannelFeedBuilder.executeContentScript('/content-scripts/get-channel-url.js'); + } + + /** + * Get the canonical address of the channel of the current page from the DOM. + * The canonical address is always based on the channel ID. + * @returns {?URL} URL object representing the channel address or `null` in case of error + */ + static async getCanonicalAddressfromDOM() { + Utils.debug('Requesting the canonical address from the DOM'); + return ChannelFeedBuilder.executeContentScript('/content-scripts/get-canonical-url.js'); + } + + /** + * Retrieve the address of the channel of the current page, and set the associated property. + * If an error happened, the internal property will be set to `null`. + * @returns {ChannelFeedBuilder} Instance of the class, in order to chain methods + */ + async getChannelAddress() { + const parts = this.url.pathname.split('/'); + const firstPathnamePart = parts.length >= 2 ? parts[1] : ''; + switch (firstPathnamePart) { + case 'user': + this.channelAddress = this.url; + break; + case 'channel': + this.channelAddress = this.url; + break; + case 'watch': + this.channelAddress = await ChannelFeedBuilder.getChannelAddressfromDOM(); + break; + default: + this.channelAddress = await ChannelFeedBuilder.getCanonicalAddressfromDOM(); + break; + } + Utils.debug(`Channel address set to [${this.channelAddress}]`); + return this; + } + + /** + * Build the unique identifier of the found channel, and set the associated property. + * If an error happened, the internal property will be set to `null`. + * @returns {ChannelFeedBuilder} Instance of the class, in order to chain methods + */ + buildChannelIdentifier() { + const parts = this.channelAddress !== null ? this.channelAddress.pathname.split('/') : []; + const firstPathnamePart = parts.length >= 2 ? parts[1] : ''; + switch (firstPathnamePart) { + case 'channel': + this.channelIdentifier = `channel_id=${parts[2]}`; + break; + case 'user': + this.channelIdentifier = `user=${parts[2]}`; + break; + default: + this.channelIdentifier = null; + break; + } + Utils.debug(`Channel identifier set to [${this.channelIdentifier}]`); + return this; + } + + /** + * Build the RSS feed of the channel from the built unique identifier. + * @returns {?string} The channel RSS feed, or `null` if an error happened + */ + buildChannelFeed() { + return this.channelIdentifier !== null + ? `https://www.youtube.com/feeds/videos.xml?${this.channelIdentifier}` + : null; + } +} diff --git a/src/background/Utils.js b/src/background/Utils.js new file mode 100644 index 0000000..61d75f7 --- /dev/null +++ b/src/background/Utils.js @@ -0,0 +1,43 @@ +/** + * A class containing various utility functions. + */ +class Utils { + /** + * Log a debug message or payload to the console (if debug is enabled only). + * @param {*} payload The message string or payload to log to the console + */ + static debug(payload) { + if (!DEBUG) return; + if (typeof payload === 'string') { + // eslint-disable-next-line no-console + console.info(`[YRF] ${payload}`); + } else { + // eslint-disable-next-line no-console + console.info(payload); + } + } + + /** + * Create an instance of the URL object from an URL string. + * @param {string} urlString URL in a string format + * @returns {?URL} The constructed URL object or `null` if the URL is invalid + */ + static buildUrlObject(urlString) { + try { + return new URL(urlString); + } catch (e) { + return null; + } + } + + /** + * Check if the provided URL is valid (HTTP-based URL). + * @param {string} urlString The URL to check + * @returns {boolean} `true` if the URL is supported, `false` otherwise + */ + static isValidURL(urlString) { + const supportedProtocols = ['https:', 'http:']; + const url = Utils.buildUrlObject(urlString); + return url === null ? false : supportedProtocols.indexOf(url.protocol) !== -1; + } +} diff --git a/src/background/background.js b/src/background/background.js new file mode 100644 index 0000000..ad1a3ae --- /dev/null +++ b/src/background/background.js @@ -0,0 +1,163 @@ +/** + * Global variable. + * Hold the resolved channel RSS feed for the curent YouTube page if it exists or `null` otherwise. + * @type {?string} + */ +let rssFeed = null; +/** + * Global constant. + * Intended for development purposes only. If set to `true`, debug messages will display. + * @type {boolean} + */ +const DEBUG = false; + +/* + * ================================================================================================= + * EXTENSION LOGIC + * ================================================================================================= + */ + +/** + * Tru to retrieve the channel RSS feed of the current YouTube page. + * If it exists, shows the page action to the user. + * @param {number} tabId The ID of the current tab + * @param {URL} url An instance of the URL object for the current tab address + */ +async function retrieveFeed(tabId, urlString) { + const url = Utils.buildUrlObject(urlString); + if (url === null) return; + + Utils.debug(`Trying to retrieve the feed for the current page [${urlString}]`); + const feedBuilder = new ChannelFeedBuilder(tabId, url); + const feed = (await feedBuilder.getChannelAddress()) + .buildChannelIdentifier() + .buildChannelFeed(); + + if (!Utils.isValidURL(feed)) { + rssFeed = null; + Utils.debug(`Feed is invalid - determined as [${feed}]`); + return; + } + + Utils.debug(`Feed has been determined as [${feed}]`); + rssFeed = feed; + browser.pageAction.show(tabId); +} + +/** + * Process the change happening for the given tab ID. + * This can be either a change of address within the tab or activating a new one. + * Disable the page action icon and try to retrieve the channel feed if applicable. + * @param {?number} tabId ID of the current tab (set to `null` if called from window focus change) + * @param {?number} windowId ID of the current window (set to `null` if called from tab update) + */ +async function tabHasChanged(tabId, windowId) { + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + url: 'https://www.youtube.com/*', + }); + if (windowId !== null && tabs[0].windowId !== windowId) return; + if (tabs.length === 0 || (tabId !== null && tabs[0].id !== tabId)) return; + rssFeed = null; + browser.pageAction.hide(tabs[0].id); + await retrieveFeed(tabs[0].id, tabs[0].url); +} + +/** + * Callback function executed when the page action is clicked. + * Open the YouTube channel RSS feed in a new tab if it exists. + * Otherwise, displays a notification error message to the user. + */ +async function onPageActionClick() { + if (rssFeed !== null) { + Utils.debug('Feed exists - Opening in new tab'); + browser.tabs.create({ + url: rssFeed, + active: true, + }); + } else { + Utils.debug('Feed does not exist - Displaying notification'); + browser.notifications.create('', { + type: 'basic', + title: browser.runtime.getManifest().name, + message: browser.i18n.getMessage('feedRetrievalError'), + iconUrl: browser.extension.getURL('icons/error/error-96.png'), + }); + } +} + +/** + * Callback function executed when the current tab is updated. + * If the URL of the current tab changes, try to retrieve the RSS feed for it. + * @param {number} tabId The ID of the current tab + * @param {Object} changeInfo Contains properties for the tab properties that have changed + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onUpdated#changeInfo `changeInfo` object definition} + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab `Tab` type definition} + */ +async function onTabUpdated(tabId, changeInfo) { + /** + * Only listening to changes in the `title` property, once the proper page title has been set. + * Before the page title is set, it will default to 'YouTube' + * Listening to `status` property changes (either `loading` or `complete`) will fail, + * as the content page script will be executed before the DOM has actually been updated. + */ + if (!changeInfo.title || changeInfo.title === 'YouTube') return; + Utils.debug(`Tab with ID [${tabId}] has been updated`); + await tabHasChanged(tabId, null); +} + +/** + * Callback function executed when the current tab is activated. + * @param {Object} activeInfo Contains properties regarding the current tab + * @see {@link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onActivated#activeInfo `activeInfo` object definition} + */ +async function onTabActivated(activeInfo) { + Utils.debug(`Tab with ID [${activeInfo.tabId}] has been activated`); + await tabHasChanged(activeInfo.tabId, activeInfo.windowId); +} + +/** + * Callback function executed when the currently focused window changes. + * @param {number} windowId ID of the newly focused window + */ +async function onWindowFocusChanged(windowId) { + Utils.debug(`Window with ID [${windowId}] has been gained focus`); + await tabHasChanged(null, windowId); +} + +/* + * ================================================================================================= + * LISTENERS + * ================================================================================================= + */ + +// ------------------------------------------------------------------------------------------------- +// TABS +// ------------------------------------------------------------------------------------------------- +// Listen to tab URL changes (if version supports it, limit to necessary URLs and events) +browser.runtime.getBrowserInfo().then(info => { + const mainVersion = parseInt(info.version.split('.')[0], 10); + if (mainVersion >= 61) { + browser.tabs.onUpdated.addListener(onTabUpdated, { + urls: ['https://www.youtube.com/*'], + properties: ['title'], + }); + } else { + browser.tabs.onUpdated.addListener(onTabUpdated); + } +}); +// Listen to tab activation and tab switching +browser.tabs.onActivated.addListener(onTabActivated); + +// ------------------------------------------------------------------------------------------------- +// WINDOWS +// ------------------------------------------------------------------------------------------------- +// Listen for window activation and window switching +browser.windows.onFocusChanged.addListener(onWindowFocusChanged); + +// ------------------------------------------------------------------------------------------------- +// PAGE ACTION +// ------------------------------------------------------------------------------------------------- +// Listen for clicks on the page action (icon in the address bar) +browser.pageAction.onClicked.addListener(onPageActionClick); diff --git a/src/content-script.js b/src/content-script.js deleted file mode 100644 index 883e3c3..0000000 --- a/src/content-script.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Check if the provided input is nil (`null` or `undefined`) - * @param {*} input The input to test - * @returns {boolean} `true` if the input is nil, `false` otherwise - */ -function isNil(input) { - return input === null || input === undefined; -} - -/** - * Retrieve the YouTube channel URL from the DOM. - * @returns {(string|null)} The URL of the channel or `null` if not found - */ -function findChannelAddress() { - /** - * List of selectors to retrieve the channel homepage address from the DOM. - * Supports both new and old YouTube layouts. - * Must be defined in the function body to avoid redeclaration issues. - * @type {Array} - */ - const CHANNEL_URL_SELECTORS = [ - 'yt-formatted-string#owner-name :first-child', - '.yt-user-info :first-child', - ]; - let channelAddress = null; - // eslint-disable-next-line - for (let urlSelector of CHANNEL_URL_SELECTORS) { - if (!isNil(channelAddress)) break; - const container = window.document.querySelector(urlSelector); - if (!isNil(container)) channelAddress = container.href; - } - return channelAddress; -} - -// Notify the extension by returning the found URL -findChannelAddress(); diff --git a/src/content-scripts/get-canonical-url.js b/src/content-scripts/get-canonical-url.js new file mode 100644 index 0000000..72826e6 --- /dev/null +++ b/src/content-scripts/get-canonical-url.js @@ -0,0 +1,37 @@ +/** + * Intended for development purposes only. If set to `true`, debug messages will display. + * @returns {boolean} `true` if debug is enabled, `false` otherwise + */ +function isDebugEnabled() { + return false; +} + +/** + * Log a debug message or payload to the console (if debug is enabled only). + * @param {*} payload The message string or payload to log to the console + */ +function debug(payload) { + if (!isDebugEnabled()) return; + const output = typeof payload === 'string' ? `[YRF] ${payload}` : payload; + // eslint-disable-next-line no-console + console.info(output); +} + +/** + * Retrieve the current page canonical URL from the DOM. + * @returns {?string} The canonical URL of the current page or `null` if not found + */ +function findCanonicalAddress() { + debug('Trying to locate the canonical URL'); + const canonicalLink = document.querySelector("link[rel='canonical']"); + if (canonicalLink === null) { + debug('Failed to locate the canonical URL'); + return null; + } + debug(`Successfully located the canonical URL as [${canonicalLink.href}]`); + return canonicalLink.href; +} + +// Notify the extension by returning the result +debug('Content script execution was requested'); +findCanonicalAddress(); diff --git a/src/content-scripts/get-channel-url.js b/src/content-scripts/get-channel-url.js new file mode 100644 index 0000000..8efeab7 --- /dev/null +++ b/src/content-scripts/get-channel-url.js @@ -0,0 +1,63 @@ +/** + * Intended for development purposes only. If set to `true`, debug messages will display. + * @returns {boolean} `true` if debug is enabled, `false` otherwise + */ +function isDebugEnabled() { + return false; +} + +/** + * Log a debug message or payload to the console (if debug is enabled only). + * @param {*} payload The message string or payload to log to the console + */ +function debug(payload) { + if (!isDebugEnabled()) return; + const output = typeof payload === 'string' ? `[YRF] ${payload}` : payload; + // eslint-disable-next-line no-console + console.info(output); +} + +/** + * Check if the provided input is nil (`null` or `undefined`) + * @param {*} input The input to test + * @returns {boolean} `true` if the input is nil, `false` otherwise + */ +function isNil(input) { + return input === null || input === undefined; +} + +/** + * Get the list of selectors to retrieve the channel homepage address from the DOM. + * Supports both new and old YouTube layouts. + * @returns {array} List of selectors + */ +function getChannelSelectors() { + return [ + 'yt-formatted-string#owner-name :first-child', // New layout + '.yt-user-info :first-child', // Old layout + ]; +} + +/** + * Retrieve the YouTube channel URL from the DOM. + * @returns {(string|null)} The URL of the channel or `null` if not found + */ +function findChannelAddress() { + debug('Trying to locate the channel URL'); + let channelAddress = null; + + getChannelSelectors().some(channelSelector => { + const container = window.document.querySelector(channelSelector); + if (isNil(container)) return false; + debug(`Successfully located the channel URL as [${container.href}]`); + channelAddress = container.href; + return true; + }); + + if (channelAddress === null) debug('Failed to locate the channel URL'); + return channelAddress; +} + +// Notify the extension by returning the result +debug('Content script execution was requested'); +findChannelAddress(); diff --git a/src/manifest.json b/src/manifest.json index 10ba4eb..ee3896d 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -10,6 +10,7 @@ "default_locale": "en", "permissions": [ + "https://www.youtube.com/*", "activeTab", "tabs", "notifications" @@ -29,12 +30,7 @@ "512": "icons/logo/youtube-rss-finder-512.png", "1024": "icons/logo/youtube-rss-finder-1024.png" }, - "browser_style": true, - "show_matches": [ - "https://www.youtube.com/channel/*", - "https://www.youtube.com/user/*", - "https://www.youtube.com/watch*" - ] + "browser_style": true }, "icons": { @@ -51,7 +47,11 @@ }, "background": { - "scripts": ["background.js"] + "scripts": [ + "background/Utils.js", + "background/ChannelFeedBuilder.js", + "background/background.js" + ] }, "applications": {