How to detect page title change in Google Chrome from an extension? - javascript

I'm creating a Google Chrome extension and I need to detect when a page's title changes. The page's title is changed like in Twitter: (num) Twitter (see the screenshot below) - when a new tweet is posted, the number increments. Example:
I'm trying to detect the title changes of a URL that's loaded in one of my tabs and play a beep sound whenever there's a difference. This check is to be done in a repeated interval and I think that can be accomplished using setTimeOut() function.
I've created a manifest.json as follows:
{
"manifest_version": 2,
"name": "Detect Page Title Changes",
"description": "Blah",
"version": "1.0",
"browser_action": {
"default_icon": "icon.png",
"default_popup": "background.html"
},
"permissions": [
"tabs"
]
}
However, I'm clueless about the rest. I've searched through the docs 1 2 and tried the solutions on similar Stack Overflow threads such as this one I but couldn't find anything that suits my requirements.
Do you have any suggestions? Please include an example, if possible.

Instead of arguing in comments that a certain approach is better, let me be more constructive and add an answer by showing a particular implementation I co-wrote myself, and explain some gotchas you may run into. Code snippets refer to a service different from Twitter, but the goal was the same. In fact, this code's goal is to report the exact number of unread messages, so yours might be simpler.
My approach is based on an answer here on SO, and instead of being polling-driven (check condition at fixed intervals) is event-driven (be notified of potential changes in condition).
Advantages include immediate detection of a change (which would otherwise not be detected until the next poll) and not wasting resources on polls while the condition does not change. Admittedly, the second argument hardly applies here, but the first one still stands.
Architecture at a glance:
Inject a content script into the page in question.
Analyze initial state of the title, report to background page via sendMessage.
Register a handler for a title change event.
Whenever the event fires and the handler is called, analyze the new state of the title, report to background page via sendMessage.
Already step 1 has a gotcha to it. Normal content script injection mechanism, when the content script is defined in the manifest, will inject it in pages upon navigation to a page that matches the URL.
"content_scripts": [
{
"matches": [
"*://theoldreader.com/*"
],
"js": ["observer.js"],
"run_at": "document_idle"
}
]
This works pretty well, until your extension is reloaded. This can happen in development as you're applying changes you've made, or in deployed instances as it is auto-updated. What happens then is that content scripts are not re-injected in existing open pages (until navigation happens, like a reload). Therefore, if you rely on manifest-based injection, you should also consider including programmatic injection into already-open tabs when extension initializes:
function startupInject() {
chrome.tabs.query(
{url: "*://theoldreader.com/*"},
function (tabs) {
for (var i in tabs) {
chrome.tabs.executeScript(tabs[i].id, {file: "observer.js"});
}
}
);
}
On the other end, content script instances that were active at the time of extension reload are not terminated, but are orphaned: any sendMessage or similar request will fail. It is, therefore, recommended to always check for exceptions when trying to communicate with the parent extension, and self-terminate (by removing handlers) if it fails:
try {
chrome.runtime.sendMessage({'count' : count});
} catch(e) { // Happens when parent extension is no longer available or was reloaded
console.warn("Could not communicate with parent extension, deregistering observer");
observer.disconnect();
}
Step 2 also has a gotcha to it, though it depends on the specifics of the service you're watching. Some pages inside the scope of the content script will not show the number of unread items, but it does not mean that there are no new messages.
After observing how the web service works, I concluded that if the title changes to something without navigation, it's safe to assume the new value if correct, but for the initial title "no new items" should be ignored as unreliable.
So, the analysis code accounts for whether it's the initial reading or handling an update:
function notify(title, changed) {
// ...
var match = /^\((\d+)\)/.exec(title);
var match_zero = /^The Old Reader$/.exec(title);
if (match && match[1]) {
count = match[1];
} else if (match_zero && changed) {
count = 0;
}
// else, consider that we don't know the count
//...
}
It is called with the initial title and changed = false in step 2.
Steps 3 & 4 are the main answer to "how to watch for title changes" (in an event-driven way).
var target = document.querySelector('head > title');
var observer = new window.MutationObserver(
function(mutations) {
mutations.forEach(
function(mutation){
notify(mutation.target.textContent, true);
}
);
}
);
observer.observe(target, { subtree: true, characterData: true, childList: true });
For specifics as to why certain options of observer.observe are set, see the original answer.
Note that notify is called with changed = true, so going from "(1) The Old Reader" to "The Old Reader" without navigation is considered to be a "true" change to zero unread messages.

Put chrome.tabs.onUpdated.addListener in your background script:
chrome.tabs.onUpdated.addListener(function(tabId, changeInfo, tab) {
console.log(changeInfo);
});
changeInfo is an object which includes title changes, e.g. here:
Can then filter on the object so that an action only occurs if changeInfo includes a title change. For additional manipulation, e.g. responding to page title changes with page content / actions, you can send a message to content script from inside the listener after whatever conditions are met.

Create an event page.
Create a content script that gets injected into a webpage when a webpage loads.
Within the content script, use setInterval to poll the page to see if window.document.title changes.
If the title has changed, use chrome.runtime.sendMessage to send a message to your event page.
On your event page, listen for messages with chrome.runtime.onMessage and play a sound.

After researching Chrome's tabs API, it doesn't look like anything stands out to help you directly. However, you should be able to attach an event listener to the title node of the tab(s) you're interested in. The DOMSubtreeModified mutation event works in Chrome, and a quick test in a normal html document proves to work for me - should be no different from within an extension.
var title = document.getElementsByTagName('title')[0];
if (title) {
title.addEventListener('DOMSubtreeModified', function (e) {
// title changed
}, false);
}

Related

Intercept and replace images in web app with Chrome extension

I am attempting to write a chrome extension (for personal use) to swap/replace images loaded by a webpage with alternate images. I'd had this working for some time using chrome.webRequest, but am attempting to bring it up-to-speed with manifest v3.
My general solution is that I am hosting my replacement images on my own server, including a script to retrieve as json a list of such images. I fetch that list and, for each image, create a dynamic redirect rule with chrome.declarativeNetRequest.updateDynamicRules.
This all works beautifully if I request an image to be replaced in a main frame. I can see the successful match with an onRuleMatchedDebug listener, and (of course) the path is dutifully redirected.
However, when I load the web app that in turn loads the image (with javascript, presumably with xmlhttprequest?), the redirect rule does not trigger. The initiator (a javascript source file) is on the same domain and similar path to the images being replaced.
//manifest.json
{
"name": "Image replace",
"description": "Replace images in web app",
"version": "2.0",
"manifest_version": 3,
"background": {"service_worker": "background.js"},
"permissions": [
"declarativeNetRequestWithHostAccess",
// "declarativeNetRequestFeedback" // Not necessary once tested
],
"host_permissions" : [
// "https://domain1.com/outerframe/*", // Not necessary
"https://domain2.com/innerframe/*",
"https://domain3.com/*",
"https://myexample.com/*"
]
}
// background.js
//chrome.declarativeNetRequest.onRuleMatchedDebug.addListener((info) => console.log(info)); // Not necessary once tested
var rules = [];
var idx = 1;
fetch("https://myexample.com/list") // returns json list like: ["subdir1\/image1.png", "subdir1\/image2.png", "subdir2\/image1.png"]
.then((response) => response.json())
.then((data) => {
console.log(data);
for (const path of data) {
var src = "https://domain2.com/innerframe/v*/files/" + path; // wildcards a version number
var dst = "https://myexample.com/files/" + path;
rules.push({
"id" : idx++,
"action" : {
"type": "redirect",
"redirect": {
"url": dst
}
},
"condition" : {
"urlFilter": src,
// In the end I only needed main_frame, image, and not xmlhttprequest
"resourceTypes": ["main_frame", "image"]
}
});
}
chrome.declarativeNetRequest.updateDynamicRules({"addRules": rules, "removeRuleIds" : rules.map(r => r.id)});
});
Again, this DOES all work IF I load a source image directly in chrome, but fails when it's being loaded by the javascript app.
I also attempted to test the match by specifying the proper initiator with testMatchOutcome, but my browser seems to claim this API does not exist. Not at all sure what could be wrong here.
// snippet attempted after above updateDynamicRules call
chrome.declarativeNetRequest.testMatchOutcome({
"initiator": "https://domain2.com/innerframe/files/script.js",
"type": "xmlhttprequest",
"url": "https://domain2.com/innerframe/v001/files/subdir/image1.png"
}, (outcome) => console.log(outcome));
I would expect a redirect to "https://myexample.com/files/subdir/image1.png"
Instead, I get this error:
Uncaught (in promise) TypeError: chrome.declarativeNetRequest.testMatchOutcome is not a function
Documentation https://developer.chrome.com/docs/extensions/reference/declarativeNetRequest/#method-testMatchOutcome says it's supported in chrome 103+. I'm running chrome 108.0.5359.72
Thanks!
Edit: Example code updated to reflect my answer below.
I've managed to work out why direct requests were redirected while script loaded ones were not. My problem was with the initiator and host permissions. I had been relying on Chrome developer tools to provide the initiator, which in the above example originated with domain2.com. However, the actual host permission I needed was from a third domain (call it domain3.com), which seems to be the source of the content that loaded scripts from domain2.com.
I discovered this when I recalled that host permissions allows "<all_urls>", which is not a good idea long term, but it did allow the redirects to complete. From there, my onRuleMatchedDebug listener could fire and log to the console the characteristics of the redirect, which showed me the proper initiator I was missing.
Having a concise view of the redirects I need, I can now truncate some of these options to only the ones actually needed (edited in original question).
Subsequent to that I thought to look back at the HTTP requests in developer tools and inspect the Referer header, which also had what I was needing.
So, silly oversights aside, I would like to leave this question open a little while longer in case anyone has any idea why chrome.declarativeNetRequest.testMatchOutcome seems unavailable in Chrome 108.0.5359.72 but is documented for 103+. I'd chalk it up to the documentation just being wrong, but it seems this function must have shipped at some point and somehow was erroneously removed? Barring any insights, I might just submit it as a bug.

Detect page changes that'd don't refresh in Chrome Extension Javascript + Clay.js

Background
I am trying to write a Google Chrome extension to detect whenever a user scrolls down on Facebook, and if they successfully load a new set of posts, change the icon for one of the reaction options for all posts. I am using clay.js to detect if the div that contains the Facebook feed has resized, which means more posts have loaded / comments have been posted. This works fine.
Problem
The problem arises when you swap between pages on Facebook without refreshing. For example, if you start on your Home page, this will work fine. However, when you swap to your profile, the script no longer runs, until you refresh the page. Once refreshed, the script works perfectly again. I know I'm missing something about how my file is being loaded, so my question is: how do I run my script on every Facebook page, without having to refresh between each type of page?
Relevant Code (reaction-changer.js)
const fbContentId = "#content"
// on DOM load, watch for future feed scrolling
document.addEventListener('DOMContentLoaded', checkFeedUpdate(), false);
function checkFeedUpdate(){
let currFeed = new Clay(fbContentId)
// resize occurs whenever the user scrolls down or a comment loads
// on a prexisting post
currFeed.on('resize', function() {
switchAllIcons()
});
}
Manifest (some elements omitted for simplicity, notated by ...). change-icons.js is the script that actually changes icons, which will run fine, if the reaction-changer.js script actually runs.
{
...
"version": "1.0",
"manifest_version": 2,
"content_scripts": [
{
"matches": ["https://www.facebook.com/*"],
"js": ["extension/clay.js", "change-icons.js", "reaction-changer.js"],
"all_frames": true
}
],
"web_accessible_resources": [
"img/*.png"
]
...
}
Any help would be greatly appreciated! I've read the Chrome Extension documentation, as well as a bunch of other stack overflow posts, but must have missed a solution somewhere.
Alrighty, I spent the last 2 hours working on this, and I found a solution that I'm happy with for now (albeit not content with -- but what'll ya do). Basically, the big question that I had in my OP was:
how do I run my script on every Facebook page, without having to refresh between each type of page?
Well, what I realized is that, yes, refreshing is the solution. So... what if we force a refresh on Facebook's end, allowing the DOM to refresh, and the code to run as expected? I believe that this PROBABLY is actually an underlying issue with how the Clay.js library I'm using is implemented. Anyway, I basically approached the solution by:
First, creating a background.js file that takes advantage of chrome.tabs.onUpdated.addListener -- this function basically let me detect if a tab changed or if the page status was "completed" indicating it has loaded.
If it loaded, then I run the function checkFeedUpdate() exactly as above.
If it changed to a new page (e.g., user clicked from Home to Profile), I force a reload, and then wait for point 2 above to fire.
'background.jsis detecting whether or not these states have happened yet, and relaying the information toreaction-changer.js`.
Here's the updated bit of reaction-changer.js (in place of document.addEventListener):
chrome.runtime.onMessage.addListener(
function(request, sender, sendResponse) {
// listen for messages sent from background.js
if (request.message === 'reload') {
location.reload();
} else if (request.message === 'start'){
checkFeedUpdate()
}
});
Here's the updated manifest:
"background": {
"scripts": ["background.js"]
},
"content_scripts": [
{
"matches": ["https://www.facebook.com/*"],
"js": ["extension/clay.js", "change-icons.js", "reaction-changer.js"],
"all_frames": true
}
],
"permissions": [
"tabs"
],
"web_accessible_resources": [
"img/*.png"
]
And here's what I created for background.js:
chrome.tabs.onUpdated.addListener(
function(tabId, changeInfo) {
// read changeInfo data and do something with it
if (changeInfo.url) {
chrome.tabs.sendMessage( tabId, {
message: 'reload'
})
} else if (changeInfo.status === 'complete'){
chrome.tabs.sendMessage( tabId, {
message: 'start'
})
}
}
);
If anyone ends up facing a similar issue (it seems like refreshing does the trick, but you can't get it to work without refreshing), it seems that just forcing a refresh might be a good solution. If there's a better one, please let me know!

Executing code at page-level from Background.js and returning the value

I've got a web page with its own scripts and variables that I need to execute and retrieve return values from my extension's Background.js.
I understand (I think!) that in order to interact with the web page, it must be done via chrome.tabs.executeScript or a ContentScript, but because the code must execute in the context of the original page (in order to have scope to the scripts and variables), it needs to be injected into the page first.
Following this great post by Rob W, I'm able to invoke the page-level script/variables, but I'm struggling to understand how to return values in this way.
Here's what I've got so far...
Web page code (that I want to interact with):
<html>
<head>
<script>
var favColor = "Blue";
function getURL() {
return window.location.href;
}
</script>
</head>
<body>
<p>Example web page with script content I want interact with...</p>
</body>
</html>
manifest.json:
{
// Extension ID: behakphdmjpjhhbilolgcfgpnpcoamaa
"name": "MyExtension",
"version": "1.0",
"manifest_version": 2,
"description": "My Desc Here",
"background": {
"scripts": ["background.js"]
},
"icons": {
"128": "icon-128px.png"
},
"permissions": [
"background",
"tabs",
"http://*/",
"https://*/",
"file://*/", //### (DEBUG ONLY)
"nativeMessaging"
]
}
background.js
codeToExec = ['var actualCode = "alert(favColor)";',
'var script = document.createElement("script");',
' script.textContent = actualCode;',
'(document.head||document.documentElement).appendChild(script);',
'script.parentNode.removeChild(script);'].join('\n');
chrome.tabs.executeScript( tab.id, {code:codeToExec}, function(result) {
console.log('Result = ' + result);
} );
I realise the code is currently just "alerting" the favColor variable (this was just a test to make sure I could see it working). However, if I ever try returning that variable (either by leaving it as the last statement or by saying "return favColor"), the executeScript callback never has the value.
So, there appear to be (at least) three levels here:
background.js
content scripts
actual web page (containing scripts/variables)
...and I would like to know what is the recommended way to talk from level 1 to level 3 (above) and return values?
Thanks in advance :o)
You are quite right in understanding the 3-layer context separation.
A background page is a separate page and therefore doesn't share JS or DOM with visible pages.
Content scripts are isolated from the webpage's JS context, but share DOM.
You can inject code into the page's context using the shared DOM. It has access to the JS context, but not to Chrome APIs.
To communicate, those layers use different methods:
Background <-> Content talk through Chrome API.
The most primitive is the callback of executeScript, but it's impractical for anything but one-liners.
The common way is to use Messaging.
Uncommon, but it's possible to communicate using chrome.storage and its onChanged event.
Page <-> Extension cannot use the same techniques.
Since injected page-context scripts do not technically differ from page's own scripts, you're looking for methods for a webpage to talk to an extension. There are 2 methods available:
While pages have very, very limited access to chrome.* APIs, they can nevertheless use Messaging to contact the extension. This is achieved through "externally_connectable" method.
I have recently described it in detail this answer. In short, if your extension declared that a domain is allowed to communicate with it, and the domain knows the extension's ID, it can send an external message to the extension.
The upside is directly talking to the extension, but the downside is the requirement to specifically whitelist domains you're using this from, and you need to keep track of your extension ID (but since you're injecting the code, you can supply the code with the ID). If you need to use it on any domain, this is unsuitable.
Another solution is to use DOM Events. Since the DOM is shared between the content script and the page script, an event generated by one will be visible to another.
The documentation demonstrates how to use window.postMessage for this effect; using Custom Events is conceptually more clear.
Again, I answered about this before.
The downside of this method is the requirement for a content script to act as a proxy. Something along these lines must be present in the content script:
window.addEventListener("PassToBackground", function(evt) {
chrome.runtime.sendMessage(evt.detail);
}, false);
while the background script processes this with a chrome.runtime.onMessage listener.
I encourage you to write a separate content script and invoke executeScript with a file attribute instead of code, and not rely on its callback. Messaging is cleaner and allows to return data to background script more than once.
The approach in Xan's answer (using events for communication) is the recommended approach. Implementing the concept (and in a secure way!) is however more difficult.
So I'll point out that it is possible to synchronously return a value from the page to the content script. When a <script> tag with an inline script is inserted in the page, the script is immediately and synchronously executed (before the .appendChild(script) method returns).
You can take advantage of this behavior by using the injected script to assign the result to a DOM object which can be accessed by the content script. For example, by overwriting the text content of the currently active <script> tag. The code in a <script> tag is executed only once, so you can assign any rubbish to the content of the <script> tag, because it won't be parsed as code any more. For example:
// background script
// The next code will run as a content script (via chrome.tabs.executeScript)
var codeToExec = [
// actualCode will run in the page's context
'var actualCode = "document.currentScript.textContent = favColor;";',
'var script = document.createElement("script");',
'script.textContent = actualCode;',
'(document.head||document.documentElement).appendChild(script);',
'script.remove();',
'script.textContent;'
].join('\n');
chrome.tabs.executeScript(tab.id, {
code: codeToExec
}, function(result) {
// NOTE: result is an array of results. It is usually an array with size 1,
// unless an error occurs (e.g. no permission to access page), or
// when you're executing in multiple frames (via allFrames:true).
console.log('Result = ' + result[0]);
});
This example is usable, but not perfect. Before you use this in your code, make sure that you implement proper error handling. Currently, when favColor is not defined, the script throws an error. Consequently the script text is not updated and the returned value is incorrect. After implementing proper error handling, this example will be quite solid.
And the example is barely readable because the script is constructed from a string. If the logic is quite big, but the content script in a separate file and use chrome.tabs.executeScript(tab.id, {file: ...}, ...);.
When actualCode becomes longer than a few lines, I suggest to wrap the code in a function literal and concatenate it with '(' and ')(); to allow you to more easily write code without having to add quotes and backslashes in actualCode (basically "Method 2b" of the answer that you've cited in the question).
chrome.browserAction.onClicked.addListener(function(tab) {
// No tabs or host permissions needed!
console.log('Turning ' + tab.url + ' red!');
chrome.tabs.executeScript({
file: 'index.js'
});
});
Here index.js is normal js file to inject in browser
#index.js
alert("Hello from api");

Prompt on URL visit

I'm new to Javascript and Chrome extensions, and I'm working on one that triggers a prompt when the user visits a certain webpage, but It's not prompting when the user visits the webpage.
Here's a simplified version that reproduces the problem:
chrome.tabs.query({'active': true, 'lastFocusedWindow': true}, function (tabs) {
var url = tabs[0].url;
if (url === "example.com") {
confirm("You are visiting " + URL);
}
});
What I was hoping this would do is trigger the prompt when the user visits 'Example.com', but I think that I'm misunderstanding how Chrome pulls the URL from the active tab, and maybe how the event is triggered...
Any tips are appreciated.
In your answer, you quoted this question, and yet you picked a weird answer out of the bunch of answers there.
Instead of detecting a page load yourself, it's best to rely on Chrome's content script mechanism, which will ensure that your script is executed in every tab that loads the requested webpage.
If you know the list of the webpages in advance, you can filter by them in the manifest:
"content_scripts" : [{
"matches": ["*://*.example1.com/*", "*://*.example2.com/"],
"js": ["confirm.js"]
}],
And then have a very simple confirm.js:
confirm("You are visiting " + location.href);
This will be very efficient as Chrome will natively filter the requests for you.
If you don't know the list of the webpages in advance, you can use a content script on every page and filter it in your code:
"content_scripts" : [{
"matches": ["*://*/*"],
"js": ["confirm.js"]
}],
And the confirm.js (skeleton):
chrome.storage.local.get("hostFilterData", function(data){
if( checkUrl(location.host, data.hostFilterData) ) {
confirm("You are visiting " + location.href);
}
});
function checkUrl(location.host, filterData){
/* your logic goes here */
}
You might also want to add "run_at" : "document_start" if you want your JS to run at the earliest time possible.
If you really want to keep your logic in the background page, you can listen to various events. chrome.tabs.onUpdated filtered by changeInfo.status == 'complete' should work in most cases, but may not always be a good idea. It will fire very late if the page has a slow-loading resource, and might fire again if the page uses anchor-based navigation (you probably don't want that). Even if you go this route, there's little sense doing a tab query after that, since the event includes the tab ID of the page that fired it.
There's also chrome.webNavigation API providing relevant events.
Try changing
if(url === "example.com")
to
if(url === "example.com" || url === "http://example.com" || url === "https://example.com")

"chrome.permissions is not available" when requesting optional permissions for my extension

I am building an extension that requires access to history to provide one of the features.
After publishing a version which contained the permission as mandatory and consequently losing a part of my users because they got scared away by the big alert saying that the extension might be able to snoop into their history (I really didn't plan on doing that), I decided to publish a version with the offending part removed and the permission disabled as a temporary fix.
I'd like to implement this feature back using optional permissions.
First of all, I added the new optional permission to my manifest file:
...
"permissions": [
"https://news.ycombinator.com/",
"http://news.ycombinator.com/"
],
"optional_permissions": [ "history" ],
...
Then, I built a function to request permissions into the script which handles the extension's settings:
Settings.prototype.applyPermissions = function (permissions, map) {
Object.keys(permissions).forEach(function (key) {
if (map[key]) {
var checkbox = map[key].getElementsByTagName("input")[0];
checkbox.addEventListener("change", function (e) {
if (this.checked) {
chrome.permissions.request(permissions[key], function(granted) {
if (granted) {
// Permission has been granted
} else {
// Not granted
}
});
}
});
}
});
};
The key part here is this:
checkbox.addEventListener("change", function (e) {
if (this.checked) {
chrome.permissions.request(permissions[key], function(granted) {
if (granted) {
// Permission has been granted
} else {
// Not granted
}
});
}
});
I perform the request on an event caused by user interaction (the guide states that it won't work otherwise), and pass permissions[key], an object specified in my extension's settings which looks like this:
"permissions": {
"mark_as_read": {
"permissions": ["history"]
}
}
When accessing it as permissions[key], I get this part:
{
"permissions": ["history"]
}
Which is basically the format that the documentation shows for this kind of requests.
If I run this code and toggle the checkbox that should enable the feature, and look at the error log, I see this error:
chrome.permissions is not available: You do not have permission to
access this API. Ensure that the required permission or manifest
property is included in your manifest.json.
I also tried accessing this API from a background page, where it was actually available but I was not allowed to use because Chrome requires that you access it from a user interaction, and such interaction is lost if you send a message to the background page from your content script to request activation.
Am I missing something obvious here? Maybe I need to add something to the manifest, but I can't find any explicit documentation about it.
I assume you're trying to do this from a content script. You can't access most chrome.* APIs from content scripts, including chrome.permissions. However, you've correctly pointed out that a background page is also unsuitable, because you a permission change requires a direct user action.
Luckily, we have hardly exhausted our options. You could set the permission in:
The extension's options page
A browser action popup
A page action popup
Any page in your extension served through the chrome-extension:// scheme, provided you include the page and necessary sub-resources as web_accessible_resources in your manifest
In the last case, get the URL using chrome.extension.getURL. You could possibly use an iframe to inject it directly into the page, if you don't want the permission-requesting interface to be separate from the current page.
So, in fact, content scripts and background pages are the only two extension contexts where you can't use chrome.permissions.

Categories

Resources