Making Concourse CI Links Clickable with Violentmonkey and Claude
TL;DR
Turn plain text URLs in Concourse CI logs into clickable links:
- Install Violentmonkey extension in Chrome
- Create a userscript that finds and converts URLs to clickable links
- The script works on all Concourse pages and handles dynamic content
The Problem
Concourse CI displays URLs in build logs as plain text, making it tedious to copy and paste them into a new tab. This is especially annoying when dealing with test reports, artifacts, and external service links that appear frequently in CI/CD pipelines.
The Solution: A Userscript
Step 1: Install Violentmonkey
Violentmonkey is a browser extension that lets you run custom JavaScript on any website.
To install in Chrome:
- Open Chrome Web Store
- Search for "Violentmonkey"
- Click "Add to Chrome"
- Confirm the installation
Step 2: Using Claude to Analyze the Page
This is where things get interesting. Instead of manually inspecting the Concourse interface, I used Claude's Playwright MCP server to navigate and analyze the page structure.
Installing the Playwright MCP Server
First, add the Playwright MCP server to Claude:
claude mcp add playwright npx @playwright/mcp@latestThis gives Claude the ability to control a browser, navigate to pages, take screenshots, and analyze DOM elements.
The Discovery Process
Here's how I prompted Claude to help me create the userscript:
Initial Navigation
"I want to develop a Violentmonkey userscript that makes HTML links clickable on my Concourse CI webpage. Use the Playwright MCP server to navigate to my Concourse instance and prompt me so I can login."Waiting for Authentication
Since my Concourse instance uses Google OAuth, I told Claude:
"I'm logged in. Navigate to the pipeline page."This let me handle the authentication manually while Claude waited.
Navigating to the Right Page
Concourse has a specific structure: pipelines → jobs → builds → tasks. I guided Claude:
"Navigate to the [pipeline-name] pipeline, and navigate to the [job-name] job and search the [task-name] task for HTML links to convert to anchor tags."Then I clarified:
"Note that once you land on the jobs page, you will see a list of builds. You have to click on an actual build before then clicking on the task."Identifying the Links
Initially, I asked Claude to focus on specific types of URLs (Google Cloud Storage links), but then realized I wanted something more universal:
"Tweak the script so that it works for *all* http/https URLs that appear on the page, not just specific URLs"
Step 3: The Final Userscript
Here's the userscript that resulted from this collaboration:
// ==UserScript==
// @name Concourse CI - Make URLs Clickable
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Convert all plain text HTTP/HTTPS URLs to clickable links in Concourse CI logs
// @author Your Name
// @match https://your-concourse-instance.com/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// Function to convert URLs in text nodes to clickable links
function makeUrlsClickable(node) {
// Skip if this node has already been processed
if (node.dataset && node.dataset.urlsProcessed) {
return;
}
// Regular expression to match all http and https URLs
const urlRegex = /https?:\/\/[^\s<>"]+/g;
// Get all text nodes within the given node
const walker = document.createTreeWalker(
node,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
// Skip if parent is already a link or script/style
const parent = node.parentNode;
if (parent.tagName === 'A' ||
parent.tagName === 'SCRIPT' ||
parent.tagName === 'STYLE' ||
parent.tagName === 'NOSCRIPT') {
return NodeFilter.FILTER_REJECT;
}
// Only process if contains our target URLs
if (urlRegex.test(node.nodeValue)) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
}
}
);
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
// Process each text node
textNodes.forEach(textNode => {
const text = textNode.nodeValue;
const matches = text.match(urlRegex);
if (matches && matches.length > 0) {
const parent = textNode.parentNode;
const fragment = document.createDocumentFragment();
let lastIndex = 0;
// Reset regex for exec
urlRegex.lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
// Add text before the URL
if (match.index > lastIndex) {
fragment.appendChild(
document.createTextNode(text.substring(lastIndex, match.index))
);
}
// Create clickable link
const link = document.createElement('a');
link.href = match[0];
link.textContent = match[0];
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.style.color = '#4FC3F7';
link.style.textDecoration = 'underline';
link.style.wordBreak = 'break-all';
fragment.appendChild(link);
lastIndex = match.index + match[0].length;
}
// Add remaining text after last URL
if (lastIndex < text.length) {
fragment.appendChild(
document.createTextNode(text.substring(lastIndex))
);
}
// Replace the text node with the fragment containing links
parent.replaceChild(fragment, textNode);
}
});
// Mark this node as processed
if (node.dataset) {
node.dataset.urlsProcessed = 'true';
}
}
// Function to process the page
function processPage() {
// Find all elements that might contain logs
const logContainers = document.querySelectorAll('pre, .build-step, .step-body, [class*="output"], [class*="log"]');
if (logContainers.length > 0) {
logContainers.forEach(container => {
makeUrlsClickable(container);
});
} else {
// If no specific containers found, process the whole body
makeUrlsClickable(document.body);
}
}
// Initial processing
setTimeout(processPage, 1000);
// Set up observer for dynamic content
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if this is a log container or contains log content
if (node.tagName === 'PRE' ||
node.classList && (
node.classList.contains('build-step') ||
node.classList.contains('step-body') ||
Array.from(node.classList).some(c => c.includes('output')) ||
Array.from(node.classList).some(c => c.includes('log'))
)) {
makeUrlsClickable(node);
} else if (node.querySelector) {
// Check for log containers within the added node
const containers = node.querySelectorAll('pre, .build-step, .step-body, [class*="output"], [class*="log"]');
containers.forEach(container => makeUrlsClickable(container));
}
}
});
}
});
});
// Start observing the body for changes
observer.observe(document.body, {
childList: true,
subtree: true
});
// Also run when URL changes (for single-page navigation)
let lastUrl = location.href;
new MutationObserver(() => {
const url = location.href;
if (url !== lastUrl) {
lastUrl = url;
setTimeout(processPage, 1000);
}
}).observe(document, {subtree: true, childList: true});
})();Step 4: Installing the Userscript
- Click the Violentmonkey icon in your browser toolbar
- Click the "+" button or "Create a new script"
- Replace the default content with the script above
- Update the
@matchline to point to your Concourse instance URL - Save the script (Ctrl+S or Cmd+S)
Conclusion
Combining Claude's ability to navigate web pages (via Playwright MCP) with browser userscripts creates a powerful workflow for enhancing any web application.
The next time you find yourself repeatedly copying and pasting URLs from a web application, consider creating a userscript. And if the page structure is complex, let Claude help you navigate and analyze it!
Webmentions
No mentions yet.
🙏🙏🙏
Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.