Skip to content

Commit 5143e89

Browse files
Paul Mulliganclaude
andcommitted
feat: Chrome Web Store release preparation
Add privacy policy, changelog, manifest improvements (side_panel declaration, short_name, minimum_chrome_version, remove redundant activeTab permission), deduplicate page extraction logic into shared module, lower highlighter z-index, fix TS build error, fix README model reference, and capture store screenshots. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e87a46c commit 5143e89

30 files changed

Lines changed: 966 additions & 205 deletions

app/CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Changelog
2+
3+
All notable changes to AI SEO Copilot will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [0.1.0] - 2026-04-01
9+
10+
### Added
11+
12+
- SEO analysis with 25 checks across 5 categories: Content, Technical, Meta Tags, Headings, and Images
13+
- Weighted scoring system (0-100) with per-category subscores
14+
- AI-powered recommendations using OpenAI GPT-4o-mini:
15+
- Title tag suggestions
16+
- Meta description rewrites
17+
- H2 keyword optimization
18+
- Image alt text generation
19+
- JSON-LD structured data templates
20+
- Side panel UI with dark theme
21+
- Per-tab analysis scoping (each tab keeps its own results)
22+
- Editable recommendations with copy-to-clipboard
23+
- Multi-language support (9 languages)
24+
- Onboarding overlay for first-time users
25+
- Score celebration animation (confetti at 80+)
26+
- Options page for API key management
27+
- JavaScript-rendered site detection with user warnings
28+
- Keyboard-accessible UI

app/PRIVACY_POLICY.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Privacy Policy — AI SEO Copilot
2+
3+
**Last updated:** April 1, 2026
4+
5+
AI SEO Copilot is a Chrome browser extension that analyzes web pages for SEO issues and provides recommendations. This privacy policy explains what data the extension accesses, how it is used, and how it is stored.
6+
7+
## Data the Extension Accesses
8+
9+
When you click "Analyze" on a page, the extension reads the following from the current tab:
10+
11+
- Page title, meta tags, and heading structure
12+
- Image elements and their alt text attributes
13+
- Link counts (internal vs. external)
14+
- Open Graph and Twitter Card meta tags
15+
- Structured data (JSON-LD schema markup)
16+
- Word count and paragraph content
17+
- The page URL
18+
19+
This data is used **only** to generate the SEO score and recommendations shown in the extension's side panel. It is never sent to our servers or any third party other than OpenAI (see below).
20+
21+
## OpenAI API Key and AI Features
22+
23+
The extension's AI-powered recommendations (title suggestions, meta description rewrites, alt text generation, etc.) require an OpenAI API key that **you** provide.
24+
25+
- Your API key is stored locally in your browser's `chrome.storage.local`. It never leaves your device except when making direct API calls from your browser to OpenAI's servers (`api.openai.com`).
26+
- When you request an AI recommendation, a small amount of page context (e.g., the current title, keyword, or image src) is sent to OpenAI's API to generate the suggestion. Only the minimum context needed for the specific recommendation is sent.
27+
- We do not proxy, log, or store any data sent to or received from OpenAI. Your browser communicates directly with OpenAI's API.
28+
- You can delete your stored API key at any time from the extension's Settings page.
29+
30+
For OpenAI's own data handling practices, see [OpenAI's Privacy Policy](https://openai.com/policies/privacy-policy) and [API Data Usage Policy](https://openai.com/policies/api-data-usage-policies).
31+
32+
## Data Storage
33+
34+
All extension data is stored locally on your device using Chrome's built-in storage APIs:
35+
36+
| Data | Storage Location | Purpose |
37+
|------|-----------------|---------|
38+
| OpenAI API key | `chrome.storage.local` | Authenticating AI recommendation requests |
39+
| Language preference | `chrome.storage.local` | Generating recommendations in your preferred language |
40+
| Per-tab analysis results | `chrome.storage.session` | Preserving results while navigating between tabs |
41+
42+
No data is stored on external servers. No cookies are set. No analytics or tracking scripts are used.
43+
44+
## Permissions Explained
45+
46+
| Permission | Why It's Needed |
47+
|------------|----------------|
48+
| `tabs` | To identify the active tab and its URL for analysis |
49+
| `sidePanel` | To display the extension's UI in Chrome's side panel |
50+
| `storage` | To save your API key and preferences locally |
51+
| `scripting` | To read page content (DOM) for SEO analysis |
52+
| Host permission (`<all_urls>`) | To analyze any website you choose to scan |
53+
54+
## Data Sharing
55+
56+
AI SEO Copilot does **not**:
57+
58+
- Collect or transmit personal information
59+
- Use analytics, telemetry, or tracking of any kind
60+
- Share data with third parties (other than direct OpenAI API calls initiated by you)
61+
- Store any data on external servers
62+
- Access browsing history beyond the currently analyzed tab
63+
64+
## Children's Privacy
65+
66+
This extension is not directed at children under 13 and does not knowingly collect information from children.
67+
68+
## Changes to This Policy
69+
70+
If this policy is updated, the new version will be published with the extension update and the "Last updated" date above will change.
71+
72+
## Contact
73+
74+
If you have questions about this privacy policy, please open an issue on the project's GitHub repository.

app/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ For AI-powered suggestions, you need an OpenAI API key:
195195
4. Create a new secret key
196196
5. Copy the key and paste it in the extension's Settings
197197

198-
**Note:** OpenAI API usage has associated costs. The extension uses GPT-3.5-turbo which is inexpensive (typically fractions of a cent per request).
198+
**Note:** OpenAI API usage has associated costs. The extension uses GPT-4o-mini which is inexpensive (typically fractions of a cent per request).
199199

200200
---
201201

app/manifest.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
{
22
"manifest_version": 3,
33
"name": "AI SEO Copilot",
4+
"short_name": "SEO Copilot",
45
"description": "Analyze page SEO and get AI-powered recommendations",
56
"version": "0.1.0",
7+
"minimum_chrome_version": "116",
68
"icons": {
79
"16": "icons/icon-16.png",
810
"32": "icons/icon-32.png",
911
"48": "icons/icon-48.png",
1012
"128": "icons/icon-128.png"
1113
},
12-
"permissions": ["activeTab", "tabs", "sidePanel", "storage", "scripting"],
14+
"permissions": ["tabs", "sidePanel", "storage", "scripting"],
1315
"host_permissions": ["<all_urls>"],
1416
"background": {
1517
"service_worker": "src/background/service-worker.ts",
@@ -21,6 +23,9 @@
2123
"js": ["src/content/index.ts"]
2224
}
2325
],
26+
"side_panel": {
27+
"default_path": "src/sidepanel/index.html"
28+
},
2429
"options_ui": {
2530
"page": "src/options/index.html",
2631
"open_in_tab": false

app/src/background/service-worker.ts

Lines changed: 3 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { extractPageDataInline } from "@/lib/extract-page-data-inline";
2+
13
console.log("[AI SEO Copilot] Service worker initializing...");
24

35
// Disable automatic panel opening - we'll handle it manually for per-tab scoping
@@ -47,19 +49,6 @@ async function persistPanelTabs(): Promise<void> {
4749
async function addPanelTab(tabId: number): Promise<void> {
4850
panelTabs.add(tabId);
4951
await persistPanelTabs();
50-
51-
// Immediately disable the panel on ALL other tabs
52-
// This ensures the panel only shows on the tab where it was opened
53-
try {
54-
const allTabs = await chrome.tabs.query({});
55-
for (const tab of allTabs) {
56-
if (tab.id && tab.id !== tabId) {
57-
chrome.sidePanel.setOptions({ tabId: tab.id, enabled: false });
58-
}
59-
}
60-
} catch {
61-
// Ignore errors
62-
}
6352
}
6453

6554
async function removePanelTab(tabId: number): Promise<void> {
@@ -165,59 +154,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
165154
console.log("[AI SEO Copilot] Executing analyzer script...");
166155
const results = await chrome.scripting.executeScript({
167156
target: { tabId },
168-
func: () => {
169-
const getMetaContent = (name: string): string => {
170-
const el =
171-
document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`) ||
172-
document.querySelector<HTMLMetaElement>(`meta[property="${name}"]`);
173-
return el?.content ?? "";
174-
};
175-
const getHeadings = (tag: string): string[] =>
176-
Array.from(document.querySelectorAll(tag)).map(
177-
(el) => el.textContent?.trim() ?? "",
178-
);
179-
const images = Array.from(document.querySelectorAll<HTMLImageElement>("img")).map((img) => ({
180-
src: img.src, alt: img.alt ?? "", width: img.naturalWidth || null, height: img.naturalHeight || null,
181-
}));
182-
const links = Array.from(document.querySelectorAll<HTMLAnchorElement>("a[href]"));
183-
const host = window.location.hostname;
184-
let internal = 0, external = 0;
185-
for (const link of links) {
186-
try { new URL(link.href).hostname === host ? internal++ : external++; }
187-
catch { internal++; }
188-
}
189-
const bodyText = document.body?.innerText ?? "";
190-
const ogTags: Record<string, string> = {};
191-
document.querySelectorAll<HTMLMetaElement>('meta[property^="og:"]').forEach((el) => {
192-
ogTags[el.getAttribute("property")!] = el.content;
193-
});
194-
const twitterTags: Record<string, string> = {};
195-
document.querySelectorAll<HTMLMetaElement>('meta[name^="twitter:"]').forEach((el) => {
196-
twitterTags[el.getAttribute("name")!] = el.content;
197-
});
198-
const paragraphs = Array.from(document.querySelectorAll("p"))
199-
.map((el) => el.textContent?.trim() ?? "").filter(Boolean);
200-
const jsRes = Array.from(document.querySelectorAll<HTMLScriptElement>("script[src]"))
201-
.map((el) => el.src).filter(Boolean);
202-
const cssRes = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'))
203-
.map((el) => el.href).filter(Boolean);
204-
const schemas = Array.from(document.querySelectorAll<HTMLScriptElement>('script[type="application/ld+json"]'));
205-
const schemaTypes: string[] = [];
206-
for (const s of schemas) { try { const d = JSON.parse(s.textContent ?? ""); if (d["@type"]) schemaTypes.push(d["@type"]); } catch {} }
207-
return {
208-
url: window.location.href, title: document.title ?? "",
209-
metaDescription: getMetaContent("description"), metaKeywords: getMetaContent("keywords"),
210-
canonical: document.querySelector<HTMLLinkElement>('link[rel="canonical"]')?.href ?? "",
211-
h1: getHeadings("h1"), h2: getHeadings("h2"), h3: getHeadings("h3"),
212-
h4: getHeadings("h4"), h5: getHeadings("h5"), h6: getHeadings("h6"),
213-
images, ogTags, twitterTags, wordCount: bodyText.split(/\s+/).filter(Boolean).length,
214-
internalLinks: internal, externalLinks: external,
215-
lang: document.documentElement.lang ?? "", paragraphs,
216-
resources: { js: jsRes, css: cssRes },
217-
schemaMarkup: { types: schemaTypes, count: schemas.length },
218-
ogImage: getMetaContent("og:image"), imageFileSizes: [],
219-
};
220-
},
157+
func: extractPageDataInline,
221158
});
222159
console.log("[AI SEO Copilot] executeScript completed, results:", results?.length ?? 0);
223160
const data = results?.[0]?.result;

app/src/components/EditableRecommendation.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,30 @@ describe("EditableRecommendation", () => {
7575
await user.click(screen.getByTitle("Regenerate"));
7676
expect(defaultProps.onToast).toHaveBeenCalledWith("Failed to regenerate");
7777
});
78+
79+
describe("when apiKeyMissing is true", () => {
80+
it("disables the regenerate button", () => {
81+
render(<EditableRecommendation {...defaultProps} apiKeyMissing />);
82+
const btn = screen.getByTitle("Set up API key in options");
83+
expect(btn).toBeDisabled();
84+
});
85+
86+
it("shows a message about setting up the API key", () => {
87+
render(<EditableRecommendation {...defaultProps} apiKeyMissing />);
88+
expect(
89+
screen.getByText("Set up your OpenAI API key in options to use AI suggestions."),
90+
).toBeInTheDocument();
91+
});
92+
93+
it("does not call onRegenerate when button is clicked", async () => {
94+
const onRegenerate = vi.fn();
95+
const user = userEvent.setup();
96+
render(
97+
<EditableRecommendation {...defaultProps} onRegenerate={onRegenerate} apiKeyMissing />,
98+
);
99+
const btn = screen.getByTitle("Set up API key in options");
100+
await user.click(btn);
101+
expect(onRegenerate).not.toHaveBeenCalled();
102+
});
103+
});
78104
});

app/src/components/EditableRecommendation.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ interface EditableRecommendationProps {
77
initialValue: string;
88
onRegenerate: () => Promise<string>;
99
onToast: (message: string) => void;
10+
apiKeyMissing?: boolean;
1011
className?: string;
1112
}
1213

@@ -15,6 +16,7 @@ export function EditableRecommendation({
1516
initialValue,
1617
onRegenerate,
1718
onToast,
19+
apiKeyMissing = false,
1820
className,
1921
}: EditableRecommendationProps) {
2022
const [text, setText] = useState(initialValue);
@@ -85,9 +87,9 @@ export function EditableRecommendation({
8587
</button>
8688
<button
8789
onClick={handleRegenerate}
88-
disabled={loading}
89-
className="rounded-full p-1.5 text-text-secondary hover:bg-bg-300 hover:text-white transition-colors disabled:opacity-50"
90-
title="Regenerate"
90+
disabled={loading || apiKeyMissing}
91+
className="rounded-full p-1.5 text-text-secondary hover:bg-bg-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-secondary"
92+
title={apiKeyMissing ? "Set up API key in options" : "Regenerate"}
9193
>
9294
<RefreshCw
9395
className={cn("h-3.5 w-3.5", loading && "animate-spin")}
@@ -105,6 +107,11 @@ export function EditableRecommendation({
105107
rows={1}
106108
className="w-full rounded-input bg-bg-700 px-3 py-2 text-body-16 text-text-primary placeholder:text-bg-300 outline-none focus:ring-1 focus:ring-accent-blue transition-shadow resize-none overflow-hidden"
107109
/>
110+
{apiKeyMissing && (
111+
<p className="mt-2 text-body-12 text-text-secondary opacity-70">
112+
Set up your OpenAI API key in options to use AI suggestions.
113+
</p>
114+
)}
108115
</div>
109116
);
110117
}

app/src/components/H2SelectionList.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,4 +94,42 @@ describe("H2SelectionList", () => {
9494
await user.click(regenerateButtons[0]);
9595
expect(defaultProps.onToast).toHaveBeenCalledWith("Failed to regenerate");
9696
});
97+
98+
describe("when apiKeyMissing is true", () => {
99+
it("disables the Generate All button", () => {
100+
render(<H2SelectionList {...defaultProps} apiKeyMissing />);
101+
const generateAllBtn = screen.getByText("Generate All").closest("button")!;
102+
expect(generateAllBtn).toBeDisabled();
103+
});
104+
105+
it("disables all per-item regenerate buttons", () => {
106+
render(<H2SelectionList {...defaultProps} apiKeyMissing />);
107+
const regenerateButtons = screen.getAllByTitle("Set up API key in options");
108+
for (const btn of regenerateButtons) {
109+
expect(btn).toBeDisabled();
110+
}
111+
});
112+
113+
it("shows a message about setting up the API key", () => {
114+
render(<H2SelectionList {...defaultProps} apiKeyMissing />);
115+
expect(
116+
screen.getByText("Set up your OpenAI API key in options to use AI suggestions."),
117+
).toBeInTheDocument();
118+
});
119+
120+
it("does not call onRegenerateAll when Generate All is clicked", async () => {
121+
const onRegenerateAll = vi.fn();
122+
const user = userEvent.setup();
123+
render(
124+
<H2SelectionList
125+
{...defaultProps}
126+
onRegenerateAll={onRegenerateAll}
127+
apiKeyMissing
128+
/>,
129+
);
130+
const generateAllBtn = screen.getByText("Generate All").closest("button")!;
131+
await user.click(generateAllBtn);
132+
expect(onRegenerateAll).not.toHaveBeenCalled();
133+
});
134+
});
97135
});

app/src/components/H2SelectionList.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface H2SelectionListProps {
1414
onRegenerateOne: (index: number, h2Text: string) => Promise<string>;
1515
onRegenerateAll: () => Promise<string[]>;
1616
onToast: (message: string) => void;
17+
apiKeyMissing?: boolean;
1718
className?: string;
1819
}
1920

@@ -22,6 +23,7 @@ export function H2SelectionList({
2223
onRegenerateOne,
2324
onRegenerateAll,
2425
onToast,
26+
apiKeyMissing = false,
2527
className,
2628
}: H2SelectionListProps) {
2729
const [suggestions, setSuggestions] = useState<Record<number, string>>(() => {
@@ -133,12 +135,20 @@ export function H2SelectionList({
133135
variant="secondary"
134136
onClick={handleRegenerateAll}
135137
loading={loadingAll}
138+
disabled={apiKeyMissing}
139+
title={apiKeyMissing ? "Set up API key in options" : undefined}
136140
>
137141
<Sparkles className="h-3 w-3" />
138142
Generate All
139143
</Button>
140144
</div>
141145

146+
{apiKeyMissing && (
147+
<p className="text-body-12 text-text-secondary opacity-70">
148+
Set up your OpenAI API key in options to use AI suggestions.
149+
</p>
150+
)}
151+
142152
{items.map((item) => {
143153
const suggestion = suggestions[item.index] ?? "";
144154
const isLoading = loadingItems.has(item.index);
@@ -184,9 +194,9 @@ export function H2SelectionList({
184194
</button>
185195
<button
186196
onClick={() => handleRegenerateOne(item.index, item.text)}
187-
disabled={isLoading}
188-
className="rounded-full p-1.5 text-text-secondary hover:bg-bg-300 hover:text-white transition-colors disabled:opacity-50"
189-
title="Regenerate"
197+
disabled={isLoading || apiKeyMissing}
198+
className="rounded-full p-1.5 text-text-secondary hover:bg-bg-300 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-secondary"
199+
title={apiKeyMissing ? "Set up API key in options" : "Regenerate"}
190200
>
191201
<RefreshCw
192202
className={cn("h-3.5 w-3.5", isLoading && "animate-spin")}

0 commit comments

Comments
 (0)