docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts: - README.md (if missing) - AGENTS.md (AI agent contract) - PLAN.md (current sprint) - STATUS.md (where we are) - DEVELOPMENT.md (dev workflow) - DEPLOYMENT.md (deploy procedure) - TESTING.md (test strategy) - DECISIONS.md (ADR index + templates) - .github/CODEOWNERS - .github/workflows/ci.yml Preserves all existing artifacts. Refs: RugMunchMedia/fleet-template
This commit is contained in:
commit
47ba268131
310 changed files with 38429 additions and 0 deletions
31
browser-extension/background.js
Normal file
31
browser-extension/background.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Pry Browser Extension — Background Service Worker
|
||||
chrome.runtime.onInstalled.addListener(function() {
|
||||
chrome.storage.sync.get(['pryServerUrl'], function(result) {
|
||||
if (!result.pryServerUrl) {
|
||||
chrome.storage.sync.set({pryServerUrl: 'http://localhost:8002'});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener(function(message, sender, sendResponse) {
|
||||
if (message.type === 'scrape') {
|
||||
scrapeUrl(message.url, message.config).then(sendResponse);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
async function scrapeUrl(url, config) {
|
||||
try {
|
||||
const response = await fetch(config.serverUrl + '/v1/scrape', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.apiKey ? {'Authorization': 'Bearer ' + config.apiKey} : {})
|
||||
},
|
||||
body: JSON.stringify({url: url, bypassCloudflare: true})
|
||||
});
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
return {success: false, error: err.message};
|
||||
}
|
||||
}
|
||||
0
browser-extension/icons/.gitkeep
Normal file
0
browser-extension/icons/.gitkeep
Normal file
34
browser-extension/manifest.json
Normal file
34
browser-extension/manifest.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Pry — One-Click Scraper",
|
||||
"version": "1.0.0",
|
||||
"description": "Scrape any webpage with one click. Extract prices, products, reviews, and more. Send directly to your workflow.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"storage",
|
||||
"scripting",
|
||||
"notifications"
|
||||
],
|
||||
"host_permissions": [
|
||||
"http://localhost:8002/*",
|
||||
"<all_urls>"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"options_page": "options.html",
|
||||
"background": {
|
||||
"service_worker": "background.js",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
115
browser-extension/popup.html
Normal file
115
browser-extension/popup.html
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pry Scraper</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { width: 360px; font-family: -apple-system, system-ui, sans-serif; background: #0a0a0b; color: #e4e4e7; }
|
||||
.header { background: #18181b; padding: 12px 16px; border-bottom: 1px solid #27272a; display: flex; align-items: center; gap: 10px; }
|
||||
.header h1 { font-size: 16px; color: #f59e0b; }
|
||||
.content { padding: 12px 16px; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.section h2 { font-size: 13px; color: #a1a1aa; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||
.btn { display: block; width: 100%; padding: 10px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; text-align: center; margin-bottom: 8px; font-weight: 500; }
|
||||
.btn-primary { background: #f59e0b; color: #000; }
|
||||
.btn-primary:hover { background: #d97706; }
|
||||
.btn-secondary { background: #27272a; color: #e4e4e7; border: 1px solid #3f3f46; }
|
||||
.btn-secondary:hover { background: #3f3f46; }
|
||||
.btn-danger { background: #dc2626; color: #fff; }
|
||||
.btn-small { padding: 6px 12px; font-size: 12px; width: auto; display: inline-block; }
|
||||
.status { padding: 8px 12px; border-radius: 4px; font-size: 12px; margin-top: 8px; display: none; }
|
||||
.status.success { display: block; background: #065f46; color: #6ee7b7; }
|
||||
.status.error { display: block; background: #7f1d1d; color: #fca5a5; }
|
||||
.status.loading { display: block; background: #1e3a5f; color: #93c5fd; }
|
||||
.field { margin-bottom: 10px; }
|
||||
.field label { display: block; font-size: 12px; color: #a1a1aa; margin-bottom: 4px; }
|
||||
.field input, .field select, .field textarea { width: 100%; padding: 8px; background: #18181b; border: 1px solid #27272a; border-radius: 4px; color: #e4e4e7; font-size: 13px; }
|
||||
.field textarea { resize: vertical; min-height: 60px; font-family: monospace; }
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 12px; }
|
||||
.tab { padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; background: #18181b; color: #a1a1aa; border: none; }
|
||||
.tab.active { background: #f59e0b; color: #000; }
|
||||
.result-box { background: #18181b; border: 1px solid #27272a; border-radius: 4px; padding: 8px; max-height: 200px; overflow: auto; font-family: monospace; font-size: 11px; line-height: 1.4; margin-top: 8px; }
|
||||
.destinations { display: flex; gap: 4px; flex-wrap: wrap; margin: 8px 0; }
|
||||
.dest-btn { padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer; background: #27272a; color: #a1a1aa; border: 1px solid #3f3f46; }
|
||||
.dest-btn.active { background: #065f46; color: #6ee7b7; border-color: #059669; }
|
||||
.footer { padding: 8px 16px; border-top: 1px solid #27272a; font-size: 11px; color: #52525b; text-align: center; }
|
||||
a { color: #f59e0b; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span style="font-size:20px;">🔍</span>
|
||||
<h1>Pry Scraper</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="scrape">Scrape</button>
|
||||
<button class="tab" data-tab="extract">Extract</button>
|
||||
<button class="tab" data-tab="settings">Settings</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrape Tab -->
|
||||
<div class="tab-content" id="tab-scrape">
|
||||
<div class="section">
|
||||
<button class="btn btn-primary" id="scrape-current">Scrape This Page</button>
|
||||
<div class="destinations">
|
||||
<button class="dest-btn" data-dest="clipboard">📋 Clipboard</button>
|
||||
<button class="dest-btn" data-dest="slack">💬 Slack</button>
|
||||
<button class="dest-btn" data-dest="email">📧 Email</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status" id="scrape-status"></div>
|
||||
<div id="scrape-result" class="result-box" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Extract Tab -->
|
||||
<div class="tab-content" id="tab-extract" style="display:none;">
|
||||
<div class="field">
|
||||
<label>What to extract</label>
|
||||
<select id="extract-type">
|
||||
<option value="prices">Prices</option>
|
||||
<option value="products">Products</option>
|
||||
<option value="reviews">Reviews</option>
|
||||
<option value="links">All Links</option>
|
||||
<option value="images">Images</option>
|
||||
<option value="custom">Custom CSS Selector</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" id="custom-selector-field" style="display:none;">
|
||||
<label>CSS Selector</label>
|
||||
<input type="text" id="custom-selector" placeholder=".product-price, h2.title" />
|
||||
</div>
|
||||
<button class="btn btn-primary" id="extract-current">Extract from Page</button>
|
||||
<div class="status" id="extract-status"></div>
|
||||
<div id="extract-result" class="result-box" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="tab-settings" style="display:none;">
|
||||
<div class="field">
|
||||
<label>Pry Server URL</label>
|
||||
<input type="url" id="pry-server-url" placeholder="http://localhost:8002" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>API Key (optional)</label>
|
||||
<input type="password" id="pry-api-key" placeholder="Leave blank if no auth" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Default Destination</label>
|
||||
<select id="default-destination">
|
||||
<option value="clipboard">Clipboard</option>
|
||||
<option value="slack">Slack</option>
|
||||
<option value="email">Email</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="save-settings">Save Settings</button>
|
||||
<div class="status" id="settings-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Powered by <a href="#" id="pry-version">Pry v3.0</a>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
191
browser-extension/popup.js
Normal file
191
browser-extension/popup.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Pry Browser Extension — Popup Script
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadSettings();
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none');
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
// Scrape current page
|
||||
document.getElementById('scrape-current').addEventListener('click', scrapeCurrentPage);
|
||||
|
||||
// Extract from page
|
||||
document.getElementById('extract-current').addEventListener('click', extractFromPage);
|
||||
|
||||
// Show custom selector field
|
||||
document.getElementById('extract-type').addEventListener('change', function() {
|
||||
document.getElementById('custom-selector-field').style.display =
|
||||
this.value === 'custom' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Save settings
|
||||
document.getElementById('save-settings').addEventListener('click', saveSettings);
|
||||
|
||||
// Destination buttons
|
||||
document.querySelectorAll('.dest-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.dest-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
chrome.storage.sync.get(['pryServerUrl', 'pryApiKey', 'defaultDest'], function(result) {
|
||||
document.getElementById('pry-server-url').value = result.pryServerUrl || 'http://localhost:8002';
|
||||
document.getElementById('pry-api-key').value = result.pryApiKey || '';
|
||||
if (result.defaultDest) {
|
||||
document.getElementById('default-destination').value = result.defaultDest;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
chrome.storage.sync.set({
|
||||
pryServerUrl: document.getElementById('pry-server-url').value,
|
||||
pryApiKey: document.getElementById('pry-api-key').value,
|
||||
defaultDest: document.getElementById('default-destination').value
|
||||
}, function() {
|
||||
showStatus('settings-status', 'Settings saved!', 'success');
|
||||
});
|
||||
}
|
||||
|
||||
function scrapeCurrentPage() {
|
||||
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
|
||||
const url = tabs[0].url;
|
||||
showStatus('scrape-status', 'Scraping...', 'loading');
|
||||
|
||||
chrome.storage.sync.get(['pryServerUrl', 'pryApiKey'], async function(config) {
|
||||
try {
|
||||
const response = await fetch(config.pryServerUrl + '/v1/scrape', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.pryApiKey ? {'Authorization': 'Bearer ' + config.pryApiKey} : {})
|
||||
},
|
||||
body: JSON.stringify({url: url, bypassCloudflare: true})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const content = data.data.content || data.data;
|
||||
document.getElementById('scrape-result').style.display = 'block';
|
||||
document.getElementById('scrape-result').textContent =
|
||||
typeof content === 'string' ? content.substring(0, 2000) : JSON.stringify(content, null, 2);
|
||||
|
||||
// Send to destination if active
|
||||
const activeDest = document.querySelector('.dest-btn.active');
|
||||
if (activeDest) {
|
||||
sendToDestination(activeDest.dataset.dest, content, config);
|
||||
}
|
||||
|
||||
showStatus('scrape-status', 'Scraped successfully!', 'success');
|
||||
} else {
|
||||
showStatus('scrape-status', 'Scrape failed: ' + (data.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showStatus('scrape-status', 'Error: ' + err.message, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractFromPage() {
|
||||
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
|
||||
const url = tabs[0].url;
|
||||
const extractType = document.getElementById('extract-type').value;
|
||||
|
||||
// Inject content script to extract data
|
||||
chrome.scripting.executeScript({
|
||||
target: {tabId: tabs[0].id},
|
||||
func: extractDataFromPage,
|
||||
args: [extractType, document.getElementById('custom-selector').value]
|
||||
}, function(results) {
|
||||
if (results && results[0] && results[0].result) {
|
||||
document.getElementById('extract-result').style.display = 'block';
|
||||
document.getElementById('extract-result').textContent = JSON.stringify(results[0].result, null, 2);
|
||||
showStatus('extract-status', 'Extracted ' + results[0].result.length + ' items', 'success');
|
||||
} else {
|
||||
showStatus('extract-status', 'No data found', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractDataFromPage(type, customSelector) {
|
||||
const data = [];
|
||||
|
||||
if (type === 'prices') {
|
||||
document.querySelectorAll('[class*="price"], [class*="cost"], .a-price, .price').forEach(el => {
|
||||
data.push(el.textContent.trim());
|
||||
});
|
||||
} else if (type === 'products') {
|
||||
document.querySelectorAll('[class*="product"], [class*="item"], article, .card').forEach(el => {
|
||||
data.push({
|
||||
title: el.querySelector('h2, h3, [class*="title"], [class*="name"]')?.textContent?.trim() || '',
|
||||
price: el.querySelector('[class*="price"]')?.textContent?.trim() || '',
|
||||
link: el.querySelector('a')?.href || ''
|
||||
});
|
||||
});
|
||||
} else if (type === 'reviews') {
|
||||
document.querySelectorAll('[class*="review"], [class*="testimonial"]').forEach(el => {
|
||||
data.push({
|
||||
text: el.textContent.trim().substring(0, 200),
|
||||
rating: el.querySelector('[class*="star"], [class*="rating"]')?.textContent?.trim() || ''
|
||||
});
|
||||
});
|
||||
} else if (type === 'links') {
|
||||
document.querySelectorAll('a[href]').forEach(el => {
|
||||
data.push({text: el.textContent.trim(), href: el.href});
|
||||
});
|
||||
} else if (type === 'images') {
|
||||
document.querySelectorAll('img[src]').forEach(el => {
|
||||
data.push({alt: el.alt, src: el.src});
|
||||
});
|
||||
} else if (type === 'custom') {
|
||||
document.querySelectorAll(customSelector).forEach(el => {
|
||||
data.push(el.textContent.trim());
|
||||
});
|
||||
}
|
||||
|
||||
return data.filter(d => d && (typeof d === 'string' ? d.length > 0 : Object.values(d).some(v => v)));
|
||||
}
|
||||
|
||||
async function sendToDestination(dest, content, config) {
|
||||
if (dest === 'clipboard') {
|
||||
navigator.clipboard.writeText(typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// For Slack/Email, would use the Pry API
|
||||
try {
|
||||
await fetch(config.pryServerUrl + '/v1/destination/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.pryApiKey ? {'Authorization': 'Bearer ' + config.pryApiKey} : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
destination: dest,
|
||||
data: {content: content},
|
||||
config: {}
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to send to destination:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(id, message, type) {
|
||||
const el = document.getElementById(id);
|
||||
el.textContent = message;
|
||||
el.className = 'status ' + type;
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => { el.style.display = 'none'; }, 5000);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue