I Built a Chrome Extension So Jira Would Give My Tickets Back
I needed full Jira tickets in a form other scripts could read: the description, the comments and every attachment. The official API offers a token, a 5000-line JSON response and a separate authenticated call per attachment, and the SaaS alternative charges $20/month for what amounts to a curl command behind a dashboard. I built a Chrome extension instead.
jiraextractor is a privacy-first Chrome extension that does one thing. It packs a Jira ticket's entire contents into a local ZIP file, with no servers and no data collection.
The problem: three bad ways to read your own ticket
Jira shows me the ticket in the browser and makes every other route to the same data harder. These were the options:
- Manual work: copy the description, save every image and attachment one by one, then put it back together. It is slow and error-prone.
- The official API: generate an API token and request
/rest/api/3/issue/{issueKey}. The response is about 5000 lines of JSON, the description arrives in proprietary Jira Document Format, and each attachment is metadata that needs its own authenticated API call to download. - A SaaS export tool: pay a monthly fee for a service that runs option 2 for you, and send your private ticket data through their servers on the way back to you.
The third option bills $20/month to fetch a page I already have open. I am logged into Jira in my browser and the data is on the page, so my call was to read it from there.
How it works: the whole job stays in the browser
The extension reuses the session I already have. A content script scrapes the DOM, a background worker fetches the attachments and builds the archive, and Chrome's download manager saves the result.
Nothing leaves the machine except the attachment requests to Atlassian, which the browser would make anyway.
Tech stack
| Component | Technology | Why |
|---|---|---|
| Platform | Chrome Extension (Manifest V3) | Direct access to the authenticated browser context |
| DOM scraping | Content script (content.js) | Runs inside the Jira tab |
| Attachment fetch | Background service worker | Handles cross-origin requests |
| CORS fix | declarativeNetRequest | Strips the Authorization header for Atlassian media |
| Packaging | JSZip | Builds the ZIP in memory, no server needed |
| Icons | Python + Pillow | A few lines beat any web-based icon generator |
1. Triggering the extraction
The popup is one button. A click sends an extract message to the content script in the active Jira tab.
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === 'extract') {
extractTicket().then(result => {
sendResponse(result);
}).catch(error => {
sendResponse({ error: error.message });
});
return true; // Keep message channel open for async response
}
});
extractTicket() in extractor.js does the scraping. Jira's class names vary between versions and instances, so it tries a list of known selectors for the title, description, comments and attachments instead of trusting one. That fallback list is what makes the scraper hold up across instances.
2. CORS and declarativeNetRequest
The scraped attachment URLs point to domains like api.media.atlassian.com, and the background script has to fetch them. A cross-origin fetch with a header like Authorization triggers a CORS preflight OPTIONS request, and Atlassian's media servers reject that preflight.
My code never sets an Authorization header, which did not stop Chrome from sending one. Chrome sometimes attaches the session's auth headers on its own. The fix is to tell the browser to remove the header before the request leaves.
If your extension fetches Atlassian media and the preflight fails, check the outgoing headers before you debug your own code. Chrome may attach session auth headers to cross-origin requests, and Atlassian's S3-signed media URLs reject the resulting preflight. Add a declarativeNetRequest rule that strips Authorization, then confirm the GET succeeds.
chrome.declarativeNetRequest lets you define rules that the browser applies to requests itself.
chrome.runtime.onInstalled.addListener(() => {
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [1], // Clear old rule
addRules: [
{
id: 1,
priority: 1,
action: {
type: 'modifyHeaders',
requestHeaders: [
{ header: 'Authorization', operation: 'remove' }
]
},
condition: {
urlFilter: 'media.atlassian.com',
resourceTypes: ['xmlhttprequest', 'other']
}
}
]
});
});
The rule says: for any fetch from the service worker to a media.atlassian.com URL, remove the Authorization header. The preflight goes away, the plain GET goes through, and the download works.
3. Packaging with JSZip
With the attachments fetched as blobs, the background script builds the archive in memory:
- Create
ticket.jsonwith the scraped text. - Add each attachment blob to an
attachments/folder inside the virtual ZIP. - Have
JSZipgenerate the final.zipas a blob. - Call
chrome.downloads.download()to save it to disk.
For a read-only task in a session you already have, a DOM scraper can be faster to build than an integration with a large REST API. Jira's class names vary between versions, so give every field several selector fallbacks and test against more than one instance.
The output JSON is clean and predictable:
- Output JSON
- Icon Generator
{
"title": "Ticket Title",
"ticketKey": "PROJ-123",
"url": "https://...",
"extractedAt": "2024-01-01T00:00:00.000Z",
"description": "Clean text description without HTML",
"comments": [
{
"id": 1,
"author": "John Doe",
"timestamp": "3 days ago",
"body": "Comment text"
}
],
"attachments": [
{
"id": 1,
"name": "file.pdf",
"url": "https://..."
}
]
}
#!/usr/bin/env python3
from PIL import Image, ImageDraw
def create_icon(size, output_path):
# Create a new image with transparent background
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw background circle (Jira blue: #0052CC)
margin = size // 10
draw.ellipse([margin, margin, size - margin, size - margin],
fill=(0, 82, 204, 255), outline=None)
# ... more drawing logic
img.save(output_path, 'PNG')
Checklist for a scraper extension like this
- Confirm the task is read-only and runs in a session you are already logged into.
- Give the title, description, comments and attachments several selector fallbacks each.
- Return
truefrom theonMessagelistener when the response is async. - Add a
declarativeNetRequestrule that removesAuthorizationformedia.atlassian.combefore fetching attachments. - Build the ZIP in memory with
JSZipand save it withchrome.downloads.download().
What I learned
- Scraping can beat the API. For read-only work in an authenticated session, a careful DOM scraper was faster to build than handling the REST API's format and per-attachment calls.
declarativeNetRequestis the header tool to learn in Manifest V3. In my judgment it is faster and safer than the oldwebRequestAPI for any extension that talks to third-party resources.- Keep it client-side.
JSZipplus theDownloadsAPI runs the whole process on the user's machine, which is faster, cheaper and safer than sending ticket data through a backend. - Write the small tools yourself. The extension ships three PNG icons, and a web-based icon generator or a large npm dependency is more machinery than three PNG icons need. A short Pillow script I own and understand did the job.
Full extension file structure
jiraextractor/
manifest.json
popup.html
popup.js
content.js
background.js
extractor.js
lib/
jszip.min.js
icons/
icon16.png
icon48.png
icon128.png
create_icons.py
License
The repository includes an MIT LICENSE.
