diff --git a/CLAUDE.md b/CLAUDE.md index eb5a535..d7380e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,27 +2,29 @@ ## Project Overview -Chrome extension (Manifest V3) that scrapes Amazon seller product listings, provides analytics for resellers/arbitrage, and includes a floating AI chatbot on Amazon pages powered by Gemini API. No server required — everything runs client-side. +Chrome extension (Manifest V3) that scrapes Amazon search results and seller storefronts and saves them as Excel, CSV or JSON. Its main surface is an on-page dock, bottom-right on Amazon, that suggests Scrape on search and storefront pages, follows the run, downloads the files, and holds the Gemini chat (Ask). The popup is a fallback and holds the key and the optional sign-in. No server required; everything runs client-side. -## Architecture (v2.3) +## Architecture (v2.4) ```text AmazonSellerScraper/ -├── manifest.json # Extension config (v2.3) +├── manifest.json # Extension config (v2.4) ├── popup/ # UI Layer -│ ├── popup.html # Popup interface (dashboard + settings) +│ ├── popup.html # Popup: this tab, fallback Scrape, last scrape, settings │ ├── popup.css # Popup styling │ ├── popup.js # UI logic │ └── ai-key.js # Gemini key field (AI chat settings) ├── scripts/ │ ├── content/ │ │ ├── scraper.js # Parses a search page, reports it to the SW (parse only) -│ │ ├── chatbot.js # Floating AI chatbot widget (Shadow DOM) -│ │ └── offer-fetcher.js # Seller price fetching for spread analysis +│ │ ├── offer-fetcher.js # Seller price fetching for spread analysis (watchSpread for the dock) +│ │ ├── dock-styles.js # DOCK_CSS, adopted as a constructed stylesheet +│ │ └── dock.js # The on-page dock: Scrape, Ask, settings (closed Shadow DOM) │ ├── lib/ │ │ ├── parsers.js # Pure search/offer parsing (global Parsers) │ │ ├── messages.js # Every message type and who may send it (global Msg) │ │ ├── run.js # Run state machine, bound to one tab (global Run) +│ │ ├── page-kind.js # search / storefront / seller / store / never, and hidden pages (global PageKind) │ │ ├── flags.js # Build flags (global Flags); CLOUD_SYNC is on from 2.3 │ │ ├── migrate.js # Storage schema migrations, run by the SW │ │ └── chat.js # Gemini request builder, run scoping, error text @@ -30,6 +32,7 @@ AmazonSellerScraper/ │ │ ├── service-worker.js # Wires router, engine, chat, auth and migrations │ │ ├── router.js # The one typed message router │ │ ├── engine.js # The run engine; the only writer of run data +│ │ ├── download.js # DOWNLOAD for the dock: data: URL to chrome.downloads, or back to the page │ │ ├── db.js # IndexedDB: runs, products, placements, lastValues, outbox, spread │ │ ├── sync-plan.js # Outbox entry -> cloud writes (pure) │ │ └── sync.js # Drains the outbox into Firestore, one entry at a time @@ -40,8 +43,6 @@ AmazonSellerScraper/ │ └── exporter.js # Excel/CSV/JSON export ├── packages/ │ └── schema/index.js # Cloud schema: types, validators, ids (shared with the dashboard) -├── styles/ -│ └── chatbot.css # Chatbot widget styles (loaded into Shadow DOM) ├── tests/ # Test suite (Jest) │ ├── setup/ │ │ ├── chrome-mock.js # In-memory Chrome API mock @@ -69,9 +70,10 @@ AmazonSellerScraper/ | -------------------- | --------------------------------------------------------- | | `scraper.js` | Parses a page with `Parsers`, sends `PAGE_RESULT` | | `engine.js` | Run state machine, page saves, navigation, heartbeat | -| `chatbot.js` | Floating AI chatbot widget on Amazon pages (Shadow DOM) | +| `dock.js` | On-page dock: Scrape, progress, downloads, Ask, settings | +| `page-kind.js` | Which pages get Scrape, a storefront link, or no dock | | `offer-fetcher.js` | Fetches seller offer pages for price spread analysis | -| `chatbot.css` | Widget styles loaded into Shadow DOM | +| `download.js` | Files for the dock, made in the worker | | `storage.js` | Async wrapper for chrome.storage.local | | `analyzer.js` | Opportunity scoring, insights, statistics | | `spread-analyzer.js` | Price spread statistics (CV, std dev, arbitrage scoring) | @@ -84,7 +86,8 @@ AmazonSellerScraper/ The service worker is the only writer (`scripts/background/engine.js`). -1. User clicks "Start Scraping" in popup; `popup.js` sends `START_RUN {tabId}` to the SW +1. User presses Scrape in the dock; `dock.js` sends `START_RUN_HERE {maxPages?}` and the SW takes the tab + from `sender.tab.id` (`engine.startHere`). The popup's fallback sends `START_RUN {tabId}` 2. The SW pings the tab. No answer: the popup offers `chrome.tabs.reload` and starts after the reload. A page that is not a search (captcha, sign-in, product page) is refused and nothing changes 3. The SW writes the run record (`scripts/lib/run.js`) to `chrome.storage.session` under `run`, @@ -100,7 +103,11 @@ The service worker is the only writer (`scripts/background/engine.js`). 8. The run ends with a reason; Stop goes to the SW, which cancels the pending page. Closing the run's tab or taking it elsewhere ends the run as `interrupted` 9. `analyzer.js` generates insights and opportunity scores -10. User exports via `exporter.js` (Excel/CSV/JSON) +10. After each saved page the SW sends `RUN_PROGRESS` to the run's tab; the dock also polls `RUN_STATUS` + every 2 s while a run is live. `RUN_STATUS` (`engine.status`) is the run in brief (`brief`), whether it is + the asking tab's, and a `summarize` of the results once it ended. Never products, the key or the email +11. User downloads from the dock (`DOWNLOAD {format}`, made in the SW by `Exporter.build`) or the popup + (same `Exporter.build`, `Exporter.triggerDownload`) Run states: idle, starting, running, stopping, then stopped, blocked, failed or done. `reason` says why it ended: complete, stopped, blocked, selectors_broken, storage_full, @@ -137,22 +144,36 @@ Cloud paths and shapes: `packages/schema/index.js` (keep the dashboard's copy id on a shape change). Run id `{sourceId}_{startMs}`, minted once in `engine.start`; page id `p0001`; `dayKey` is the local date the run started. -### AI Chatbot (client-side, no server) - -1. `chatbot.js` injects a floating widget (bottom-right) on Amazon pages with product listings -2. Widget uses Shadow DOM to isolate styles from Amazon's CSS -3. User types a question (e.g. "What's the best deal under $30?") -4. `chatbot.js` sends `CHAT_MESSAGE` to `service-worker.js` with the question and the last few turns -5. The service worker reads the user's key from `chrome.storage.local` and the latest run from IndexedDB; the content script never sees the key -6. `scripts/lib/chat.js` builds the request: model id in `GEMINI_MODEL`, key in the `x-goog-api-key` header, titles in a fenced JSON block marked untrusted -7. Response displayed in chat bubble as text, never HTML +### The dock (2.4) + +1. `dock.js` runs on every Amazon page except cart, checkout, sign-in and account pages (`PageKind.hidden`). + Closed shadow root on `#proscan-dock-host`, `DOCK_CSS` adopted as a constructed stylesheet, system fonts, + inline SVG. No `web_accessible_resources` +2. Launcher: search or storefront suggests Scrape (one click starts); `/sp?seller=` and brand stores link to + `/s?me=ID`; elsewhere a plain ProScan button with Ask. A live run shows page x of y and a labeled Stop; + a finished run in this tab offers the Excel download. "Not now" sets `proscan.dock.notNow` in the tab's + sessionStorage; the card's open state is `proscan.dock.open` so it survives each page of a run +3. Card: Scrape tab (ready, running, done with the pages strip, hatched pages the store never had, downloads, + Compare seller prices through `runSpreadAnalysis` and `watchSpread`), Ask tab, settings (pages per run and + the suggestion via `SAVE_SETTINGS`; the key and sign-in open `popup.html#key` or `#sync` in a tab via + `OPEN_SETTINGS`) +4. Keys and passwords are never typed on Amazon. Key events stop at the dock so Amazon shortcuts do not fire + +### AI chat (client-side, no server) + +1. The dock's Ask tab asks `CHAT_STATUS` for the key state and the scan it covers +2. User types a question (e.g. "What's the best deal under $30?") +3. `dock.js` sends `CHAT_MESSAGE` to `service-worker.js` with the question and the last few turns +4. The service worker reads the user's key from `chrome.storage.local` and the latest run from IndexedDB; the content script never sees the key +5. `scripts/lib/chat.js` builds the request: model id in `GEMINI_MODEL`, key in the `x-goog-api-key` header, titles in a fenced JSON block marked untrusted +6. Response displayed in chat bubble as text, never HTML ## Setup 1. Load unpacked extension in `chrome://extensions` -2. Click the ProScan popup, expand AI chat settings, paste your Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)) -3. Navigate to Amazon seller/search page → scrape → export -4. The AI chatbot button appears in the bottom-right corner on Amazon pages with product listings +2. Open an Amazon search or storefront and press Scrape on the dock (bottom-right), then Download Excel +3. For Ask, add a Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)) from the + dock's settings (Add key) or the popup's AI chat settings ## Testing @@ -173,6 +194,8 @@ npm run test:coverage # With coverage report `firestore.rules` (`tools/contract.mjs`, `tests/contract/`); ESM files under `packages/` and `scripts/background/` load in Jest through `tests/setup/esm-to-cjs-transform.js` - `npm run test:e2e` runs the same scenarios in Chromium (`tests/e2e/`), including the worker stopped via CDP between pages and 2.0 and 2.1 builds updated mid-run +- `tests/unit/dock.test.js` runs `dock.js` in JSDOM with the shadow root forced open; `tests/e2e/dock.spec.mjs` drives + the real dock through CDP, which can pierce the closed root (`tests/e2e/lib/dock.mjs`) - HTML fixtures in `tests/fixtures/` match the exact CSS selectors the code uses - Chrome APIs (`storage`, `runtime`, `tabs`, `downloads`) are mocked in `tests/setup/chrome-mock.js` - XLSX is mocked with jest.fn() stubs in exporter.test.js; export-xlsx.test.js uses the real libs/xlsx.full.min.js @@ -183,8 +206,8 @@ npm run test:coverage # With coverage report - `chrome.storage.session`: the live run record (content scripts cannot read it) - IndexedDB: run data, in the extension origin; needs no permission - `chrome.runtime.sendMessage/onMessage`: messages, all in `scripts/lib/messages.js` -- `chrome.downloads`: file downloads -- `chrome.tabs`: `sendMessage`, `update`, `onRemoved`, `onUpdated`; none need the `tabs` permission +- `chrome.downloads`: file downloads, from the popup and from the SW for the dock (data: URL up to about 1.5 MB) +- `chrome.tabs`: `sendMessage`, `update`, `onRemoved`, `onUpdated`, `create` (settings page); none need the `tabs` permission No permission was added for the run engine: no `alarms`, `scripting`, `offscreen` or `unlimitedStorage`. `npm run lock` fails on any addition. @@ -300,8 +323,8 @@ See `docs/PRICE_SPREAD_ANALYSIS.md` for the full specification. - [x] In-popup analytics dashboard - [x] Opportunity scoring - [x] Price spread analysis (seller price variability detection) -- [x] Floating AI chatbot on Amazon pages (Gemini API, no server needed) -- [x] Shadow DOM isolation for chatbot widget +- [x] On-page dock with Scrape, progress, downloads and Ask (2.4) +- [x] Closed Shadow DOM isolation, no web-accessible files - [x] API key management in popup settings - [x] Comprehensive test suite (214 Jest tests) diff --git a/README.md b/README.md index 49d10c2..f40a11c 100644 --- a/README.md +++ b/README.md @@ -1,262 +1,85 @@ -

- ProScan Logo -

+

ProScan

-

ProScan - Amazon Product Intelligence Platform

+

Scrape any Amazon search or seller storefront in one click.
+A small dock in the corner of the page turns every results page for you and hands back an Excel file.

- AI-powered Chrome extension for Amazon product scraping, analytics, and real-time insights + The ProScan dock on an Amazon results page after a run: 141 products saved, median price, average rating, and a Download Excel button
+ Get it on the Chrome Web Store

-

- - Chrome Web Store - - Manifest V3 - Gemini AI - MIT License -

- ---- - -## Overview - -ProScan is a Chrome extension that scrapes Amazon product listings across multiple pages, runs analytics to identify arbitrage opportunities, and includes a floating AI chatbot powered by Google Gemini for real-time product Q&A -- all running client-side with no server required. - -## Key Features - -- **Multi-page scraping** -- Automatically navigates and extracts product data (name, ASIN, price, rating, reviews, Prime status) across paginated Amazon results -- **Opportunity scoring** -- Proprietary formula identifies high-value arbitrage opportunities based on rating, review velocity, and price positioning -- **Price spread analysis** -- Fetches competing seller prices for each product and calculates variability (Coefficient of Variation) to identify pricing disagreement -- a strong arbitrage signal -- **AI chatbot** -- Floating widget on Amazon pages answers questions about your last scan using Gemini Flash and your own free API key (e.g., "What's the best deal under $30?") -- **Analytics dashboard** -- Real-time stats, underpriced product detection, and quality distribution analysis -- **Multi-format export** -- Excel (with styled sheets and charts), CSV, and JSON with full analytics -- **Shadow DOM isolation** -- Chatbot widget styles are fully isolated from Amazon's CSS - -## Architecture - -``` - Chrome Extension (Client-Side) - ┌──────────────────────────────────────────────────────────┐ - │ │ - │ popup/ scripts/content/ │ - │ ├── popup.html ├── scraper.js (DOM extraction) │ - │ ├── popup.css └── chatbot.js (AI widget) │ - │ └── popup.js │ - │ scripts/background/ │ - │ scripts/modules/ └── service-worker.js │ - │ ├── storage.js (message routing + Gemini API) │ - │ ├── analyzer.js │ - │ └── exporter.js styles/ │ - │ └── chatbot.css │ - └──────────────────────────────────────────────────────────┘ -``` - -## Data Flow - -### Scraping Pipeline - -``` -User clicks "Start Scraping" - → popup.js sends START_RUN to the service worker - → The worker pings the tab (the popup offers a reload if ProScan is not - loaded there), makes a run bound to that tab and asks it to parse - → scraper.js classifies the page, extracts products and sends PAGE_RESULT; - it never writes storage and never navigates - → The worker saves the page to IndexedDB in one transaction - → After a 2 to 4 second delay the worker opens the page's Next link - with tabs.update, and the new page reports in - → Repeats until the last page or the page cap (settings.maxPages, default 20) - → The run ends with a reason: complete, stopped, blocked (captcha, - bot check, sign-in), selectors_broken, storage_full, interrupted or - updated (the extension updated mid-run) - → analyzer.js generates insights and opportunity scores - → User exports via exporter.js (Excel/CSV/JSON) -``` - -### Cloud Sync - -``` -Signed in to a ProScan account in the popup (email and password) - → Each saved page goes into the IndexedDB outbox, tagged with the account's uid; - the end of the run goes in after its pages - → A few seconds after a page, a run end, a popup open or a browser start, - the worker flushes the outbox (no alarms). After a failed flush the next - automatic one waits 30 s, doubling up to an hour - → sync.js writes one entry at a time: the page chunk, and a product and a - history document per ASIN on the page. The run header and the source go - once per run per flush. latest, prev and delta are replaced whole, never - merged; sourceIds keeps every source - → An entry leaves the outbox only once its own writes commit; a page saved - during a flush goes out before the flush returns - → An entry the rules refuse is set aside, so the entries behind it still sync; - the popup counts them and Export to ProScan tries them again - → Export to ProScan flushes right away -``` - -Write cost: a page of k products is 1 + 2k writes, plus 2 per run per flush. A 20 page run of 48 products a page is about 1,960 writes. Spark allows 20,000 writes a day for the whole project, shared by every user, so about 10 such runs a day fill it (audit F-23). - -The document shapes, ids and validators are in `packages/schema/index.js`, which the dashboard copies. Money is integer cents, unknown values are null (left out of compact points), and every document carries `sv`. A signed-out user queues nothing. If Firebase drops the session, the popup says so and the queued pages wait for the same account to sign in again. - -### AI Chatbot Flow - +## How it works + +The ProScan dock collapsed in the bottom-right corner of an Amazon search, offering Scrape for 48 products on the page + +1. **Open.** Go to an Amazon search or a seller's storefront. The dock in the bottom-right corner offers **Scrape**. There is nothing to pin or open, and no account to make. +2. **Scrape.** ProScan reads every product on the page (title, ASIN, price, rating, reviews, Prime, sponsored), then opens the next page itself, 2 to 4 seconds apart, until the last page or your page limit. **Stop** keeps what it has. +3. **Download.** When the run ends the dock says why (last page, stopped, or Amazon showed a captcha) and offers Excel, CSV or JSON. +4. **Ask.** The dock's second tab answers questions about the products you just scraped ("what's the best rated mat under $30?"), using Google Gemini and your own free API key. + +Accounts are optional. Signing in only sends your scans to the [ProScan dashboard](https://proscanbot.web.app/dashboard/), where you can track prices across runs. + +## System design + +Everything happens in your browser. The only servers involved are Google's: Gemini if you use Ask, and Firebase if you sign in to the dashboard. + +```mermaid +flowchart LR + subgraph page["Amazon tab"] + D["Dock
(closed Shadow DOM)"] + S["Scraper
reads the page, never navigates"] + end + subgraph ext["Extension service worker"] + E["Run engine
one run, bound to one tab"] + DB[("IndexedDB
runs, products, outbox")] + X["Excel, CSV, JSON"] + end + D -- "Scrape / Stop" --> E + S -- "page result" --> E + E -- "next page" --> page + E --> DB --> X + E -. "optional" .-> F[("Firestore")] --> W["ProScan dashboard"] + E -. "Ask, your key" .-> G["Gemini"] ``` -User types question in floating widget - → chatbot.js sends CHAT_MESSAGE (question + last few turns) to service-worker.js - → Service worker reads the user's key and the latest run from its storage - → scripts/lib/chat.js builds the Gemini request (model id is GEMINI_MODEL there) - → Response displayed in chat bubble as plain text -``` - -## Analytics Engine - -### Opportunity Score Formula - -``` -score = (rating * log10(reviews + 1)) / sqrt(price) -``` - -Normalized to a 1-10 scale. Higher score = better arbitrage opportunity. The formula favors: -- **High ratings** (quality signal) -- **Many reviews** (logarithmic -- diminishing returns prevent outlier dominance) -- **Lower price** (inverse square root -- moderate price sensitivity) -### Insight Detection +The service worker is the only thing that writes data or changes pages, so closing the popup, switching tabs or Chrome stopping the worker between pages does not lose a run. Full detail is in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). -| Insight | Criteria | Priority | -|---------|----------|----------| -| High price spread | CV > 30% across sellers for the same ASIN | High | -| Underpriced | 30%+ below average price with 4+ star rating | High | -| Underexposed | 4+ stars but fewer than 50 reviews | Medium | -| Price distribution | Min/max/average/median analysis | Low | -| Quality distribution | Percentage of products above 4.5 stars | Low | +## Permissions -### Price Spread Analysis +ProScan asks for `storage`, `downloads`, and access to amazon.com and the Gemini API. Nothing else. A check in CI (`npm run lock`) fails the build if anything is ever added, because every new permission makes Chrome ask existing users to approve the extension again. -After scraping, click **Analyze Price Spreads** to fetch competing seller prices for each product. The system: +## Run it -1. Fetches the offer listing page for each ASIN (2-second delay between requests) -2. Extracts all seller prices using cascading DOM selectors -3. Calculates the **Coefficient of Variation** (stdDev / mean * 100) -4. Scores arbitrage opportunity based on CV, seller count, and absolute dollar spread - -``` -Arbitrage Score = (CV / 15) * log10(sellers + 1) * min(1, spread / $20) +```bash +git clone https://github.com/EnesYilmazcode/AmazonSellerScraper.git +cd AmazonSellerScraper +npm ci +npm run build # builds the extension into dist/ +npm test # unit tests, plus a corpus of saved Amazon pages +npm run test:e2e # loads dist/ into Chromium and scrapes the saved pages, no live Amazon traffic +npm run check # permission lock, version gate and secret scan ``` -| CV Range | Signal | Meaning | -|----------|--------|---------| -| 0-5% | Very Low | Commodity pricing, tight consensus | -| 5-15% | Low | Normal variance | -| 15-30% | Moderate | Some pricing disagreement | -| 30-50% | High | Strong arbitrage signal | -| 50%+ | Very High | Major pricing disagreement | - -See [docs/PRICE_SPREAD_ANALYSIS.md](docs/PRICE_SPREAD_ANALYSIS.md) for the full feature specification. - -## Installation - -1. Clone the repository and build it: - ```bash - git clone https://github.com/EnesYilmazcode/AmazonSellerScraper.git - cd AmazonSellerScraper - npm ci - npm run build - ``` -2. Open Chrome and navigate to `chrome://extensions/` -3. Enable **Developer mode** (top-right toggle) -4. Click **Load unpacked** and select the `dist/` folder. The repo root does not load on its own, because the service worker has to be bundled. - -To use the AI chat, open the ProScan popup, expand **AI chat settings** and paste a Gemini API key (free at [aistudio.google.com/apikey](https://aistudio.google.com/apikey)). The key stays in `chrome.storage.local`; only the service worker reads it and sends it to Google. +Then open `chrome://extensions`, turn on Developer mode, click **Load unpacked** and pick `dist/`. The repo root does not load on its own because the service worker is bundled. `npm run build:dev` points sync at the local Firebase emulators instead of the real project. -For local Firebase work, `npm run build:dev` points the build at the emulators under the `demo-proscan` project. +| Folder | What's in it | +|---|---| +| [`scripts/content/`](scripts/content/) | the dock, the page scraper, seller offer fetching | +| [`scripts/background/`](scripts/background/) | the run engine, IndexedDB, downloads, sync | +| [`scripts/lib/`](scripts/lib/) | pure parsers, the run state machine, page types, the Gemini request | +| [`popup/`](popup/) | the toolbar popup, a fallback for the dock | +| [`packages/schema/`](packages/schema/) | the cloud data shapes, shared with the dashboard | +| [`tests/`](tests/) | Jest suite, the saved page corpus, the Chromium harness | +| [`tools/`](tools/) | build, store zip, permission lock, secret scan | -## Release checks +## Known gaps -`npm test` runs the Jest suite and the tool tests. `npm run check` builds, then runs the permission lock (nothing may be added over `tools/live-manifest.json`, the published v2.0 manifest), the version gate and the secret scan. `npm run zip` writes the store package to `dist-zips/` and refuses a dev build, a stray file, uncommitted changes (`node tools/zip.mjs --allow-dirty` overrides that for local tries), or any gate failure. CI runs all of these. - -The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, see its README). `npm run test:e2e` loads the built extension into Chromium and runs scrape scenarios against those pages, with every request answered locally. Run `npx playwright install --no-shell chromium` once first. Known bugs run as expected failures tagged with their audit finding id; `PROSCAN_SHOW_KNOWN=1 npm run test:e2e` shows what they fail on. - -`npm run test:contract` runs the real sync module against the Firebase emulators with the dashboard's `firestore.rules` (from `PROSCAN_RULES`, or a `web` or `proscan-web` checkout next to this repo): queues of 1, 201 and 600 products, a page added mid-flush, replace semantics across runs, create-only `firstSeenAt`, the write count per run, a product in two sources and an entry the rules refuse. It refuses to start if any of its ports is taken, and only stops the emulator processes it started. It needs the Firebase CLI and Java, and uses the `demo-proscan` project only. - -The Jest suite includes a golden corpus of saved Amazon pages (`tests/pages/`, see its README). `npm run test:e2e` loads the built extension into Chromium and runs scrape scenarios against those pages, with every request answered locally. Run `npx playwright install --no-shell chromium` once first. Known bugs run as expected failures tagged with their audit finding id; `PROSCAN_SHOW_KNOWN=1 npm run test:e2e` shows what they fail on. - -## Usage - -1. Navigate to any Amazon search results or seller page -2. Click the ProScan extension icon -3. Hit **Start Scraping** -- it will automatically paginate through results -4. View analytics in the dashboard (item count, average rating, average price) -5. Use the floating AI chatbot (bottom-right) to ask questions about products -6. Export data as **Excel** (multi-sheet with analytics), **CSV**, or **JSON** - -## Tech Stack - -| Layer | Technology | Purpose | -|-------|-----------|---------| -| Extension | Vanilla JavaScript | UI, DOM scraping, export | -| Extension | Chrome Manifest V3 | Extension framework | -| Extension | Shadow DOM | Chatbot style isolation | -| Extension | XLSX.js | Excel generation | -| AI | Google Gemini Flash (bring your own key) | Chatbot | - -## Chrome APIs Used - -- `chrome.storage.local` -- Settings, the schema version, and the signed-in account (`account`, `authNotice`, `lastSync`) -- `chrome.storage.session` -- The live run record, written by the service worker -- IndexedDB (the extension's own origin, no permission) -- Runs, products, pages, lastValues and the sync outbox. Starting a run keeps the 10 newest runs and removes older ones, except runs still waiting to sync. -- `chrome.runtime.sendMessage` / `onMessage` -- Messages, all listed in `scripts/lib/messages.js` -- `chrome.downloads` -- File export downloads -- `chrome.tabs` -- Messages to the run's tab, `tabs.update` for the next page, and `onRemoved` / `onUpdated` to notice the tab going away (none of these need the `tabs` permission) - -## Project Structure - -``` -AmazonSellerScraper/ -├── manifest.json # Extension config (Manifest V3) -├── popup/ -│ ├── popup.html # Extension popup interface -│ ├── popup.css # Popup styling (dark theme) -│ └── popup.js # UI state management and export handling -├── scripts/ -│ ├── content/ -│ │ ├── scraper.js # Parses a search page and reports it to the worker -│ │ ├── chatbot.js # Floating AI chatbot (Shadow DOM) -│ │ └── offer-fetcher.js # Seller offer page fetching for spread analysis -│ ├── lib/ -│ │ ├── parsers.js # Pure search and offer page parsing -│ │ ├── messages.js # Every message type and who may send it -│ │ ├── run.js # The run state machine and its end reasons -│ │ ├── flags.js # Build flags (cloud sync is on from 2.3) -│ │ ├── migrate.js # Storage schema migrations (schemaVersion) -│ │ └── chat.js # Gemini request builder and run scoping -│ ├── background/ -│ │ ├── service-worker.js # Wires the router, the engine, the chat and migrations -│ │ ├── router.js # The one message router -│ │ ├── engine.js # Runs scrapes; the only writer of run data -│ │ ├── db.js # IndexedDB stores -│ │ ├── sync-plan.js # What each outbox entry writes (pure) -│ │ └── sync.js # Drains the outbox into Firestore -│ └── modules/ -│ ├── storage.js # Chrome storage abstraction layer -│ ├── analyzer.js # Analytics engine + opportunity scoring -│ ├── spread-analyzer.js # Price spread statistics (CV, arbitrage score) -│ └── exporter.js # Multi-format export (Excel/CSV/JSON) -├── packages/ -│ └── schema/index.js # Cloud schema shared with the dashboard -├── styles/ -│ └── chatbot.css # Chatbot widget styles (Shadow DOM) -├── libs/ -│ └── xlsx.full.min.js # Excel generation library -└── assets/ - ├── logo.png # Project logo - └── icons/ # Extension icons (16, 48, 128px) -``` +**Compare seller prices** misses most offers on Amazon's current pages and is being rebuilt. The opportunity score is a rough heuristic, `(rating * log10(reviews + 1)) / sqrt(price)`, not a forecast. -## Developer +## Privacy -- **Enes Yilmaz** +Scans stay on your computer unless you sign in, which sends them to your dashboard. Ask sends your question and the scanned products to Google Gemini with your own key. See the [privacy policy](https://proscanbot.web.app/privacy/). -## License +## Credits -This project is licensed under the MIT License. +Built by [Enes Yilmaz](https://github.com/EnesYilmazcode). Excel files by [SheetJS](https://sheetjs.com). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..399a2d2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,182 @@ +# ProScan internals + +The deep detail behind the [README](../README.md): how a run moves through the extension, sync, the chat, the scores and the Chrome APIs it uses. + +## Data Flow + +### Scraping Pipeline + +``` +User presses Scrape in the dock (or in the popup) + → dock.js sends START_RUN_HERE; the worker takes the tab from + sender.tab.id, never from the message. The popup sends START_RUN {tabId} + → The worker pings the tab (the popup offers a reload if ProScan is not + loaded there), makes a run bound to that tab and asks it to parse + → scraper.js classifies the page, extracts products and sends PAGE_RESULT; + it never writes storage and never navigates + → The worker saves the page to IndexedDB in one transaction + → After a 2 to 4 second delay the worker opens the page's Next link + with tabs.update, and the new page reports in + → Repeats until the last page or the page cap (settings.maxPages, default 20) + → The run ends with a reason: complete, stopped, blocked (captcha, + bot check, sign-in), selectors_broken, storage_full, interrupted or + updated (the extension updated mid-run) + → After each saved page the worker sends RUN_PROGRESS to the run's tab. + Every page is a new document, so the dock rebuilds from RUN_STATUS + → analyzer.js generates insights and opportunity scores + → Download in the dock sends DOWNLOAD {format}; the worker makes the file + with Exporter.build and saves it through chrome.downloads. A file over + about 1.5 MB goes back to the page, which saves it with a download link +``` + +### The dock + +``` +On every Amazon page but cart, checkout, sign-in and account pages: + search results (/s?k=) or a storefront (/s?me=) → "Scrape" suggested + seller profile (/sp?seller=) or a brand store → "Open storefront" + anywhere else, product pages included → ProScan + Ask +Open, it is a card: Scrape (start, progress, stop, downloads, compare +seller prices) and Ask (the chat), plus settings (pages per run, the +suggestion on or off, links to the key and sign-in on ProScan's own page). +``` + +Keys and passwords are never typed on Amazon: keystrokes inside a shadow root still reach the page's own listeners. The dock's settings open ProScan's settings page instead. + +### Cloud Sync + +``` +Signed in to a ProScan account in the popup (email and password) + → Each saved page goes into the IndexedDB outbox, tagged with the account's uid; + the end of the run goes in after its pages + → A few seconds after a page, a run end, a popup open or a browser start, + the worker flushes the outbox (no alarms). After a failed flush the next + automatic one waits 30 s, doubling up to an hour + → sync.js writes one entry at a time: the page chunk, and a product and a + history document per ASIN on the page. The run header and the source go + once per run per flush. latest, prev and delta are replaced whole, never + merged; sourceIds keeps every source + → An entry leaves the outbox only once its own writes commit; a page saved + during a flush goes out before the flush returns + → An entry the rules refuse is set aside, so the entries behind it still sync; + the popup counts them and Export to ProScan tries them again + → Export to ProScan flushes right away +``` + +Write cost: a page of k products is 1 + 2k writes, plus 2 per run per flush. A 20 page run of 48 products a page is about 1,960 writes. Spark allows 20,000 writes a day for the whole project, shared by every user, so about 10 such runs a day fill it (audit F-23). + +The document shapes, ids and validators are in `packages/schema/index.js`, which the dashboard copies. Money is integer cents, unknown values are null (left out of compact points), and every document carries `sv`. A signed-out user queues nothing. If Firebase drops the session, the popup says so and the queued pages wait for the same account to sign in again. + +### AI Chatbot Flow + +``` +User types a question in the dock's Ask tab + → dock.js sends CHAT_MESSAGE (question + last few turns) to service-worker.js + → Service worker reads the user's key and the latest run from its storage + → scripts/lib/chat.js builds the Gemini request (model id is GEMINI_MODEL there) + → Response displayed in chat bubble as plain text +``` + +## Analytics Engine + +### Opportunity Score Formula + +``` +score = (rating * log10(reviews + 1)) / sqrt(price) +``` + +Normalized to a 1-10 scale. Higher score = better arbitrage opportunity. The formula favors: +- **High ratings** (quality signal) +- **Many reviews** (logarithmic -- diminishing returns prevent outlier dominance) +- **Lower price** (inverse square root -- moderate price sensitivity) + +### Insight Detection + +| Insight | Criteria | Priority | +|---------|----------|----------| +| High price spread | CV > 30% across sellers for the same ASIN | High | +| Underpriced | 30%+ below average price with 4+ star rating | High | +| Underexposed | 4+ stars but fewer than 50 reviews | Medium | +| Price distribution | Min/max/average/median analysis | Low | +| Quality distribution | Percentage of products above 4.5 stars | Low | + +### Price Spread Analysis + +After scraping, click **Compare seller prices** in the dock or the popup to fetch competing seller prices for each product. The system: + +1. Fetches the offer listing page for each ASIN (2-second delay between requests) +2. Extracts all seller prices using cascading DOM selectors +3. Calculates the **Coefficient of Variation** (stdDev / mean * 100) +4. Scores arbitrage opportunity based on CV, seller count, and absolute dollar spread + +``` +Arbitrage Score = (CV / 15) * log10(sellers + 1) * min(1, spread / $20) +``` + +| CV Range | Signal | Meaning | +|----------|--------|---------| +| 0-5% | Very Low | Commodity pricing, tight consensus | +| 5-15% | Low | Normal variance | +| 15-30% | Moderate | Some pricing disagreement | +| 30-50% | High | Strong arbitrage signal | +| 50%+ | Very High | Major pricing disagreement | + +See [docs/PRICE_SPREAD_ANALYSIS.md](docs/PRICE_SPREAD_ANALYSIS.md) for the full feature specification. + +## Chrome APIs Used + +- `chrome.storage.local` -- Settings, the schema version, and the signed-in account (`account`, `authNotice`, `lastSync`) +- `chrome.storage.session` -- The live run record, written by the service worker +- IndexedDB (the extension's own origin, no permission) -- Runs, products, pages, lastValues and the sync outbox. Starting a run keeps the 10 newest runs and removes older ones, except runs still waiting to sync. +- `chrome.runtime.sendMessage` / `onMessage` -- Messages, all listed in `scripts/lib/messages.js` +- `chrome.downloads` -- File downloads, from the popup and, for the dock, from the service worker +- `chrome.tabs` -- Messages to the run's tab, `tabs.update` for the next page, `onRemoved` / `onUpdated` to notice the tab going away, and `tabs.create` to open ProScan's settings page from the dock (none of these need the `tabs` permission) + +2.4 adds no permission, host or match pattern, and drops `web_accessible_resources`: the dock needs no file from the extension. + +## Project Structure + +``` +AmazonSellerScraper/ +├── manifest.json # Extension config (Manifest V3) +├── popup/ +│ ├── popup.html # Extension popup interface +│ ├── popup.css # Popup styling (light and dark) +│ ├── popup.js # Fallback Scrape, downloads, settings +│ └── ai-key.js # The Gemini key field +├── scripts/ +│ ├── content/ +│ │ ├── scraper.js # Parses a search page and reports it to the worker +│ │ ├── offer-fetcher.js # Seller offer page fetching for spread analysis +│ │ ├── dock-styles.js # The dock's stylesheet, as a string +│ │ └── dock.js # The on-page dock: Scrape, Ask, settings (closed Shadow DOM) +│ ├── lib/ +│ │ ├── parsers.js # Pure search and offer page parsing +│ │ ├── messages.js # Every message type and who may send it +│ │ ├── run.js # The run state machine and its end reasons +│ │ ├── page-kind.js # Search, storefront, seller or a page to stay off +│ │ ├── flags.js # Build flags (cloud sync is on from 2.3) +│ │ ├── migrate.js # Storage schema migrations (schemaVersion) +│ │ └── chat.js # Gemini request builder and run scoping +│ ├── background/ +│ │ ├── service-worker.js # Wires the router, the engine, the chat and migrations +│ │ ├── router.js # The one message router +│ │ ├── engine.js # Runs scrapes; the only writer of run data +│ │ ├── download.js # Files for the dock, through chrome.downloads +│ │ ├── db.js # IndexedDB stores +│ │ ├── sync-plan.js # What each outbox entry writes (pure) +│ │ └── sync.js # Drains the outbox into Firestore +│ └── modules/ +│ ├── storage.js # Chrome storage abstraction layer +│ ├── analyzer.js # Analytics engine + opportunity scoring +│ ├── spread-analyzer.js # Price spread statistics (CV, arbitrage score) +│ └── exporter.js # Multi-format export (Excel/CSV/JSON) +├── packages/ +│ └── schema/index.js # Cloud schema shared with the dashboard +├── libs/ +│ └── xlsx.full.min.js # Excel generation library +└── assets/ + ├── logo.png # Project logo + └── icons/ # Extension icons (16, 48, 128px) +``` + diff --git a/docs/images/dock-results.png b/docs/images/dock-results.png new file mode 100644 index 0000000..53f8c0a Binary files /dev/null and b/docs/images/dock-results.png differ diff --git a/docs/images/dock-suggest.png b/docs/images/dock-suggest.png new file mode 100644 index 0000000..f47d9d4 Binary files /dev/null and b/docs/images/dock-suggest.png differ diff --git a/manifest.json b/manifest.json index 71326e9..1062eb8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ProScan - Amazon Product Scraper", - "version": "2.3.0", + "version": "2.4.0", "description": "Scrape Amazon seller products with analytics and AI-powered product insights", "permissions": [ "storage", @@ -31,9 +31,12 @@ "scripts/modules/price.js", "scripts/lib/parsers.js", "scripts/lib/messages.js", + "scripts/lib/run.js", + "scripts/lib/page-kind.js", "scripts/content/scraper.js", - "scripts/content/chatbot.js", - "scripts/content/offer-fetcher.js" + "scripts/content/offer-fetcher.js", + "scripts/content/dock-styles.js", + "scripts/content/dock.js" ], "run_at": "document_idle" } @@ -43,11 +46,5 @@ }, "background": { "service_worker": "scripts/background/service-worker.js" - }, - "web_accessible_resources": [ - { - "resources": ["styles/chatbot.css", "libs/*.js", "assets/icons/*.png"], - "matches": ["*://*.amazon.com/*"] - } - ] + } } diff --git a/popup/ai-key.js b/popup/ai-key.js index 0cecc56..9b39b02 100644 --- a/popup/ai-key.js +++ b/popup/ai-key.js @@ -42,7 +42,7 @@ const AiKey = (() => { const show = (set, message) => { status.textContent = message || (set ? 'Key saved. The chat button on Amazon is ready.' : 'No key set. AI chat is off.'); - input.placeholder = set ? 'Key saved (hidden). Paste a new one to replace it.' : 'Paste your Gemini API key'; + input.placeholder = set ? 'Paste a new key to replace it' : 'Paste your Gemini API key'; clearBtn.classList.toggle('hidden', !set); }; diff --git a/popup/popup.css b/popup/popup.css index 2ef101a..e8ebe05 100644 --- a/popup/popup.css +++ b/popup/popup.css @@ -1,584 +1,178 @@ -/* - * ProScan Popup Styles - * - * Design system built on CSS custom properties for consistent - * theming across the extension popup interface. - */ +/* ProScan popup: the fallback to the on-page dock, in the same system. + System fonts, no remote files, light and dark with the OS. */ :root { - /* Core palette */ - --color-bg-primary: #2A343E; - --color-bg-surface: rgba(255, 255, 255, 0.05); - --color-bg-status: rgba(255, 255, 255, 0.08); - --color-text-primary: #FDFDFD; - --color-text-muted: rgba(255, 255, 255, 0.7); - - /* Accent colors */ - --color-accent-green: #4CAF50; - --color-accent-green-dark: #388E3C; - --color-accent-blue: #2196F3; - --color-accent-blue-dark: #1976D2; - --color-accent-red: #f44336; - --color-accent-red-dark: #c62828; - --color-accent-gold: #FFD700; - - /* Status colors */ - --color-status-success-bg: rgba(76, 175, 80, 0.2); - --color-status-success-text: #81C784; - --color-status-error-bg: rgba(244, 67, 54, 0.2); - --color-status-error-text: #E57373; - --color-status-warning-bg: rgba(255, 193, 7, 0.2); - --color-status-warning-text: #FFD54F; - - /* Typography */ - --font-family: 'Segoe UI', Arial, sans-serif; - --font-size-sm: 11px; - --font-size-base: 13px; - --font-size-md: 14px; - --font-size-lg: 20px; - - /* Spacing */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 12px; - --spacing-lg: 16px; - --spacing-xl: 20px; - - /* Borders */ - --radius-sm: 6px; - --radius-md: 8px; - --radius-pill: 20px; - - /* Transitions */ - --transition-fast: 0.2s ease; -} - + --font: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif; + --bg: #ffffff; + --sunken: #f2f4f6; + --ink: #1c232b; + --muted: #5a6570; + --line: #e1e5e9; + --line-strong: #c9d0d7; + --primary: #2a343e; + --primary-hover: #1c242c; + --on-primary: #ffffff; + --soft: #e8ecef; + --soft-ink: #2a343e; + --ok: #1f7a4d; + --ok-soft: #e3f3ea; + --warn: #8a4b08; + --warn-soft: #fbf0e1; + --bad: #a1261b; + --bad-soft: #fbe9e7; + --ring: #2a343e; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #2a343e; --sunken: #333e49; --ink: #ffffff; --muted: #b3bcc6; --line: #3b4753; --line-strong: #4c5967; + --primary: #ffffff; --primary-hover: #e4e8ec; --on-primary: #2a343e; --soft: #36424e; --soft-ink: #ffffff; + --ok: #5cc493; --ok-soft: #1c3329; --warn: #f0b36b; --warn-soft: #3a2c1a; --bad: #ff8a7a; --bad-soft: #3b1f1c; + --ring: #ffffff; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; } body { - background-color: var(--color-bg-primary); - color: var(--color-text-primary); - font-family: var(--font-family); - text-align: center; - margin: 0; - padding: 0; - min-width: 320px; -} - -.card { - width: 320px; - padding: var(--spacing-xl); - background-color: var(--color-bg-primary); -} - -.logo { - width: 56px; - margin-bottom: var(--spacing-sm); -} - -h1 { - font-size: var(--font-size-lg); - margin: 0 0 var(--spacing-lg) 0; - font-weight: 600; -} - -/* Dashboard Styles */ -.dashboard { - background: var(--color-bg-surface); - border-radius: var(--radius-md); - padding: var(--spacing-md); - margin-bottom: var(--spacing-md); -} - -.stat-row { - display: flex; - justify-content: space-around; - gap: var(--spacing-sm); -} - -.stat { - flex: 1; - text-align: center; -} - -.stat-value { - font-size: var(--font-size-lg); - font-weight: 700; - color: var(--color-accent-green); -} - -.stat-label { - font-size: var(--font-size-sm); - opacity: 0.7; - margin-top: var(--spacing-xs); -} - -.stat-label i { - margin-right: 3px; -} - -/* Insights Preview */ -.insights-preview { - margin-bottom: var(--spacing-md); -} - -.insight-badge { - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - background: linear-gradient(135deg, var(--color-accent-blue) 0%, var(--color-accent-blue-dark) 100%); - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-pill); - font-size: 12px; - font-weight: 500; -} - -.insight-badge i { - color: var(--color-accent-gold); -} - -.insight-badge.opportunity { - background: linear-gradient(135deg, var(--color-accent-green) 0%, var(--color-accent-green-dark) 100%); -} - -/* Status */ -.status { - font-size: var(--font-size-base); - margin-bottom: var(--spacing-lg); - padding: 10px 14px; - background: var(--color-bg-status); - border-radius: var(--radius-sm); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); -} - -.status.success { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); -} - -.status.error { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); -} - -.status.warning { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); -} - -/* Buttons */ -.primary-button, .download-button { - width: 100%; - padding: var(--spacing-md); - border: none; - border-radius: var(--radius-sm); - font-size: var(--font-size-md); - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); -} - -.primary-button { - background: linear-gradient(135deg, var(--color-accent-green) 0%, var(--color-accent-green-dark) 100%); - color: white; -} - -.primary-button:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); -} - -.primary-button.stop { - background: linear-gradient(135deg, var(--color-accent-red) 0%, var(--color-accent-red-dark) 100%); -} - -.primary-button.stop:hover { - box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3); -} - -/* Export Buttons */ -.export-buttons { - display: flex; - gap: var(--spacing-sm); - margin-top: 10px; -} - -.download-button { - flex: 1; - background: var(--color-accent-blue); - color: white; - padding: 10px var(--spacing-sm); - font-size: 12px; -} - -.download-button:hover { - background: var(--color-accent-blue-dark); - transform: translateY(-1px); -} - -.download-button.secondary { - background: rgba(255, 255, 255, 0.1); - color: #fcfefd; -} - -.download-button.secondary:hover { - background: rgba(255, 255, 255, 0.2); -} - -.download-button:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -/* Spread Analysis */ -.spread-button { - width: 100%; - padding: 10px var(--spacing-md); - border: 1px solid rgba(255, 193, 7, 0.3); - border-radius: var(--radius-sm); - font-size: var(--font-size-base); - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - background: rgba(255, 193, 7, 0.1); - color: var(--color-status-warning-text); - margin-top: 10px; -} - -.spread-button:hover { - background: rgba(255, 193, 7, 0.2); - transform: translateY(-1px); -} - -.spread-button:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - -.spread-button.stop { - background: rgba(244, 67, 54, 0.15); - border-color: rgba(244, 67, 54, 0.3); - color: var(--color-status-error-text); -} - -.spread-progress { - margin-top: var(--spacing-sm); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.spread-progress-bar { - flex: 1; - height: 6px; - background: rgba(255, 255, 255, 0.1); - border-radius: 3px; - overflow: hidden; -} - -.spread-progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--color-accent-green), #8BC34A); - border-radius: 3px; - transition: width 0.3s ease; - width: 0%; -} - -.spread-progress-text { - font-size: var(--font-size-sm); - color: var(--color-text-muted); - min-width: 40px; - text-align: right; -} - -.spread-results { - margin-top: var(--spacing-sm); - background: var(--color-bg-surface); - border-radius: var(--radius-md); - padding: var(--spacing-md); -} - -.spread-stats { - display: flex; - justify-content: space-around; - gap: var(--spacing-sm); -} - -.spread-stat { - text-align: center; -} - -.spread-stat-value { - display: block; - font-size: 18px; - font-weight: 700; - color: #FFC107; -} - -.spread-stat-label { - font-size: var(--font-size-sm); - opacity: 0.7; - margin-top: 2px; -} - -/* ProScan Cloud Auth */ -.auth-panel { - background: var(--color-bg-surface); - border-radius: var(--radius-md); - padding: var(--spacing-md); - margin-bottom: var(--spacing-md); - text-align: left; -} - -.auth-title { - font-size: var(--font-size-base); - font-weight: 600; - color: var(--color-text-muted); - margin-bottom: var(--spacing-sm); - display: flex; - align-items: center; - gap: var(--spacing-sm); -} - -.auth-title i { - color: var(--color-accent-green); -} - -.auth-input { - width: 100%; - box-sizing: border-box; - padding: 10px var(--spacing-md); - margin-bottom: var(--spacing-sm); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: var(--radius-sm); - background: rgba(0, 0, 0, 0.2); - color: var(--color-text-primary); - font-family: var(--font-family); - font-size: var(--font-size-base); - transition: border-color var(--transition-fast); -} - -.auth-input::placeholder { - color: var(--color-text-muted); -} - -.auth-input:focus { - outline: none; - border-color: var(--color-accent-green); -} - -.auth-button { - width: 100%; - padding: 10px var(--spacing-md); - border: none; - border-radius: var(--radius-sm); - font-size: var(--font-size-base); - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - background: var(--color-accent-green); - color: white; - margin-top: var(--spacing-xs); -} - -.auth-button:hover { - background: var(--color-accent-green-dark); -} - -.auth-button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.auth-error { - margin-top: var(--spacing-sm); - font-size: var(--font-size-sm); - color: var(--color-status-error-text); - text-align: center; -} - -.auth-notice { - margin-bottom: var(--spacing-sm); - font-size: var(--font-size-sm); - color: var(--color-status-warning-text); -} - -.auth-links { - display: flex; - justify-content: space-between; - margin-top: var(--spacing-sm); - font-size: var(--font-size-sm); -} - -.auth-links a, -.auth-link-button { - color: var(--color-accent-green); - background: none; - border: none; - padding: 0; - font: inherit; - cursor: pointer; - text-decoration: underline; -} - -.auth-sync { - margin-top: var(--spacing-sm); - font-size: var(--font-size-sm); - color: var(--color-text-muted); -} - -.auth-account { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-sm); -} - -.auth-account-info { - display: flex; - align-items: center; - gap: var(--spacing-sm); - min-width: 0; - font-size: var(--font-size-base); -} - -.auth-account-info i { - color: var(--color-accent-green); - flex-shrink: 0; -} - -.auth-account-email { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--color-text-primary); -} - -.auth-signout { - flex-shrink: 0; - padding: var(--spacing-xs) 10px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: var(--radius-sm); - background: rgba(255, 255, 255, 0.06); - color: var(--color-text-muted); - font-size: var(--font-size-sm); - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); -} - -.auth-signout:hover { - background: rgba(255, 255, 255, 0.12); - color: var(--color-text-primary); -} - -#authPanel { - margin-top: var(--spacing-md); -} - -.auth-help { - font-size: var(--font-size-sm); - opacity: 0.7; - margin-bottom: var(--spacing-sm); -} - -/* AI chat settings */ -.ai-settings summary { - cursor: pointer; - list-style: none; - margin-bottom: 0; -} - -.ai-settings[open] summary { - margin-bottom: var(--spacing-sm); -} - -.ai-key-actions { - display: flex; - gap: var(--spacing-sm); - align-items: stretch; -} - -.ai-key-actions .auth-signout { - margin-top: var(--spacing-xs); -} - -.ai-key-status, -.ai-key-help { - margin-top: var(--spacing-sm); - font-size: var(--font-size-sm); - color: var(--color-text-muted); -} - -.ai-key-help a { - color: var(--color-accent-green); -} - -/* Cloud Export */ -.export-cloud-button { - width: 100%; - padding: var(--spacing-md); - border: none; - border-radius: var(--radius-sm); - font-size: var(--font-size-md); - font-weight: 600; - cursor: pointer; - transition: all var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - background: linear-gradient(135deg, var(--color-accent-green) 0%, var(--color-accent-green-dark) 100%); - color: white; - margin-top: var(--spacing-sm); -} - -.export-cloud-button:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); -} - -.export-cloud-button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -/* Spinner */ -.spinner { - width: 16px; - height: 16px; - border: 2px solid transparent; - border-top-color: currentColor; - border-radius: 50%; - animation: spin 0.8s linear infinite; - display: inline-block; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* Utility */ -.hidden { - display: none !important; + width: 360px; + min-height: 200px; + background: var(--bg); + color: var(--ink); + font: 400 14px/1.45 var(--font); + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} +/* Opened in a tab from the dock's settings link */ +html:has(body.in-tab) { background: var(--sunken); min-height: 100%; } +body.in-tab { margin: 40px auto; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; } +body.in-tab .here, body.in-tab .last { display: none; } + +.hidden { display: none !important; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +button, input { font: inherit; color: inherit; } +button { cursor: pointer; background: none; border: 0; padding: 0; margin: 0; text-align: inherit; } +button:disabled { cursor: default; opacity: .55; } +:focus { outline: none; } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +a { color: var(--soft-ink); text-underline-offset: 2px; } +svg { display: block; flex: none; } + +/* Header */ +.top { display: flex; align-items: center; gap: 10px; padding: 14px 18px 4px; } +.mark { width: 32px; height: 32px; border-radius: 50%; background: #2a343e url("../assets/icons/icon128.png") center / cover; box-shadow: 0 0 0 1px var(--line-strong); display: grid; place-items: center; } +.mark svg { visibility: hidden; } +.mark svg { width: 18px; height: 18px; } +.name { font-weight: 640; font-size: 15px; margin-right: auto; } +.version { color: var(--muted); font-size: 12px; } + +main { padding: 8px 18px 16px; display: flex; flex-direction: column; gap: 18px; } + +/* This tab */ +.here { display: flex; flex-direction: column; gap: 10px; } +.chip { display: inline-flex; align-self: flex-start; align-items: center; gap: 7px; font-size: 12.5px; border-radius: 999px; padding: 3px 11px 3px 9px; background: var(--sunken); color: var(--muted); } +.chip i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; } +.chip.on { background: var(--soft); color: var(--soft-ink); } +.chip.live { background: var(--ok-soft); color: var(--ok); } +.here h1 { margin: 2px 0 0; font-size: 19px; line-height: 1.25; font-weight: 660; letter-spacing: -.01em; } +.here p { margin: 0; color: var(--muted); font-size: 13.5px; } + +.primary { height: 44px; border-radius: 12px; background: var(--primary); color: var(--on-primary); font-weight: 620; font-size: 15px; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; } +.primary:hover:not(:disabled) { background: var(--primary-hover); } +.primary svg { width: 15px; height: 15px; } +.primary.stop, .primary.muted { background: var(--bg); color: var(--ink); border: 1px solid var(--line-strong); } +.primary.stop:hover:not(:disabled), .primary.muted:hover:not(:disabled) { background: var(--sunken); } +.primary.stop svg { width: 11px; height: 11px; } +.secondary { height: 38px; border-radius: 10px; border: 1px solid var(--line-strong); background: var(--bg); font-weight: 580; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; width: 100%; } +.secondary:hover:not(:disabled) { background: var(--sunken); } +.quiet { color: var(--muted); font-weight: 560; font-size: 13px; padding: 6px; border-radius: 8px; } +.quiet:hover { color: var(--ink); background: var(--sunken); } + +/* Status line */ +.status { font-size: 13px; border-radius: 10px; padding: 9px 12px; background: var(--sunken); color: var(--ink); } +.status.info { background: var(--soft); color: var(--soft-ink); } +.status.success { background: var(--ok-soft); color: var(--ok); } +.status.warning { background: var(--warn-soft); color: var(--warn); } +.status.error { background: var(--bad-soft); color: var(--bad); } + +/* Last scrape */ +.last { display: flex; flex-direction: column; gap: 10px; } +.last-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.last-top h2 { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted); } +.last-top span { font-size: 12.5px; color: var(--muted); } +.card { background: var(--sunken); border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; } +.big { font-size: 13.5px; color: var(--muted); } +.big b { font-size: 20px; font-weight: 660; color: var(--ink); } +.big .from { display: block; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ticks { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; } +.ticks:empty { display: none; } +.ticks i { height: 6px; border-radius: 3px; background: var(--line); } +.ticks i.done { background: var(--primary); } +.ticks i.now { background: linear-gradient(90deg, var(--primary) 50%, var(--line) 50%); } +.ticks i.skip { background: repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 5px); box-shadow: inset 0 0 0 1px var(--line); } +.endline { margin: 0; font-size: 12.5px; color: var(--muted); } +.facts { display: grid; grid-template-columns: 1fr 1fr; margin: 0; } +.facts div { display: flex; flex-direction: column; padding-left: 12px; border-left: 1px solid var(--line); } +.facts div:first-child { padding-left: 0; border-left: 0; } +.facts dt { order: 2; font-size: 12px; color: var(--muted); } +.facts dd { order: 1; margin: 0; font-weight: 620; font-size: 15px; } +.export-buttons { display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: 6px; } +.download-button { height: 36px; border-radius: 10px; border: 1px solid var(--line-strong); background: var(--bg); font-weight: 580; font-size: 13.5px; display: flex; align-items: center; justify-content: center; gap: 6px; } +.download-button:hover:not(:disabled) { background: var(--sunken); } +.download-button svg { width: 14px; height: 14px; } +.download-button.primary-soft { background: var(--primary); color: var(--on-primary); border-color: transparent; } +.download-button.primary-soft:hover:not(:disabled) { background: var(--primary-hover); } + +.linkrow { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border: 1px solid var(--line); border-radius: 12px; width: 100%; } +.linkrow:hover:not(:disabled) { background: var(--sunken); } +.linkrow .l { display: flex; flex-direction: column; line-height: 1.3; margin-right: auto; } +.linkrow .l b { font-weight: 580; font-size: 13.5px; } +.linkrow .l span { color: var(--muted); font-size: 12px; } +.linkrow svg { width: 15px; height: 15px; color: var(--muted); } +.linkrow.stop .l b { color: var(--warn); } + +.spread-progress { display: flex; align-items: center; gap: 10px; } +.spread-progress-bar { flex: 1; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; } +.spread-progress-fill { height: 100%; width: 0; background: var(--primary); border-radius: 3px; } +.spread-progress-text { font-size: 12.5px; color: var(--muted); } +.spread-results { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12.5px; color: var(--muted); } +.spread-results b { display: block; font-size: 15px; color: var(--ink); } + +/* Settings */ +.settings { border: 1px solid var(--line); border-radius: 14px; overflow: hidden; } +.fold + .fold { border-top: 1px solid var(--line); } +.fold summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 10px; padding: 12px 14px; } +.fold summary::-webkit-details-marker { display: none; } +.fold summary::after { content: ""; width: 7px; height: 7px; border-right: 1.8px solid var(--muted); border-bottom: 1.8px solid var(--muted); transform: rotate(-45deg); margin-right: 4px; transition: transform .15s; } +.fold[open] summary::after { transform: rotate(45deg); } +.fold summary:hover { background: var(--sunken); } +.fold summary:focus-visible { outline-offset: -2px; } +.fold .l { display: flex; flex-direction: column; line-height: 1.3; margin-right: auto; } +.fold .l b { font-weight: 580; font-size: 13.5px; } +.dot { font-size: 12px; color: var(--muted); display: inline-flex; align-items: center; gap: 6px; } +.dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } +.dot.on { color: var(--ok); } +.dot.on::before { background: var(--ok); } +.fold-body { padding: 0 14px 14px; display: flex; flex-direction: column; gap: 8px; } +.field { width: 100%; height: 38px; border-radius: 10px; border: 1px solid var(--line-strong); background: var(--bg); padding: 0 12px; color: var(--ink); } +.field::placeholder { color: var(--muted); } +.field:focus-visible { outline: none; border-color: var(--ring); box-shadow: 0 0 0 1px var(--ring); } +.ai-key-actions { display: flex; gap: 8px; align-items: center; } +.ai-key-actions .secondary { width: auto; } +.help { font-size: 12px; color: var(--muted); } +.help:empty { display: none; } +.notice { font-size: 12.5px; border-radius: 10px; padding: 8px 10px; background: var(--warn-soft); color: var(--warn); } +.notice.error { background: var(--bad-soft); color: var(--bad); } +.auth-links { display: flex; justify-content: space-between; align-items: center; font-size: 12.5px; } +.auth-account { display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 13px; } +.auth-account-email { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.foot { margin: 0; color: var(--muted); font-size: 12px; text-align: left; } + +.spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; display: inline-block; } +@media (prefers-reduced-motion: no-preference) { + .spinner { animation: spin .8s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .spread-progress-fill { transition: width .3s; } } diff --git a/popup/popup.html b/popup/popup.html index d80473f..4719582 100644 --- a/popup/popup.html +++ b/popup/popup.html @@ -1,7 +1,8 @@ - + - + + ProScan @@ -9,151 +10,118 @@ + -
- -

ProScan

+
+ + ProScan + +
- -
-
-
-
0
-
Items
-
-
-
-
-
Avg Rating
-
-
-
-
-
Avg Price
-
-
-
+
+ +
+ This tab +

Open a search or a storefront

+

ProScan scrapes Amazon search results and seller storefronts.

+ + + +
- -
diff --git a/popup/popup.js b/popup/popup.js index 8753165..db4f443 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -1,9 +1,10 @@ /** * @fileoverview Popup UI Controller * - * Manages the extension popup interface including the analytics dashboard, - * scraping controls, export buttons, and real-time progress updates. - * Depends on Storage, Analyzer, and Exporter modules loaded via popup.html. + * The popup is the fallback to the dock on the page (scripts/content/dock.js). + * It says what the current tab is, offers the same Scrape, follows the run, + * downloads the latest run's files, and holds the two things that must never + * be typed on an Amazon page: the Gemini key and the ProScan sign-in. * * State Management: * The service worker owns the run and everything it found. The popup never @@ -13,9 +14,12 @@ * * Communication: * - START_RUN / STOP_RUN / GET_STATE to the worker (scripts/lib/messages.js) - * - PING to a tab that did not answer, to start after a reload + * - PING to the active tab: what kind of page it is, and to start after a reload * - SPREAD_PROGRESS and SPREAD_ANALYSIS_COMPLETE from the offer fetcher * + * Opened in a tab as popup.html#key or #sync (the dock's settings links), + * it unfolds that setting. + * * @module PopupUI * @requires Storage * @requires Analyzer @@ -31,6 +35,9 @@ let currentResults = []; /** @type {Object} Spread data of the latest run, ASIN to offers */ let currentSpread = {}; +/** @type {?Object} The latest run record, from the worker */ +let currentRun = null; + /** @type {number} Page of the live run the results were last fetched for */ let renderedPage = -1; @@ -43,32 +50,58 @@ let currentProScanUser = null; /** @type {boolean} Whether a cloud export to ProScan is in progress */ let isExporting = false; +/** @type {?{kind: string, count: number, url: string, where: Object}} The active tab, as last seen */ +let currentTab = null; + +/** Inline icons: the popup loads nothing from the network. */ +const ICONS = { + play: '', + stop: '', + down: '', + spin: '' +}; + +/** Sets a button to an icon and a text label; the label is text, never HTML. */ +function setButton(button, icon, label) { + button.innerHTML = icon ? ICONS[icon] : ''; + button.appendChild(document.createTextNode((icon ? ' ' : '') + label)); +} + /** * Cached references to DOM elements used throughout the popup lifecycle. - * Resolved once at module load time for performance. * @const {Object} */ const elements = { itemCount: document.getElementById('itemCount'), + itemWord: document.getElementById('itemWord'), avgRating: document.getElementById('avgRating'), avgPrice: document.getElementById('avgPrice'), status: document.getElementById('status'), + tabChip: document.getElementById('tabChip'), + tabChipText: document.getElementById('tabChipText'), + hereTitle: document.getElementById('hereTitle'), + hereSub: document.getElementById('hereSub'), actionButton: document.getElementById('actionButton'), reloadTabButton: document.getElementById('reloadTabButton'), + lastScrape: document.getElementById('lastScrape'), + lastWhen: document.getElementById('lastWhen'), + lastSource: document.getElementById('lastSource'), + lastTicks: document.getElementById('lastTicks'), + lastLine: document.getElementById('lastLine'), exportButtons: document.getElementById('exportButtons'), downloadExcel: document.getElementById('downloadExcel'), downloadCSV: document.getElementById('downloadCSV'), downloadJSON: document.getElementById('downloadJSON'), - insightsPreview: document.getElementById('insightsPreview'), - insightBadge: document.getElementById('insightBadge'), - insightText: document.getElementById('insightText'), spreadButton: document.getElementById('spreadButton'), + spreadLabel: document.getElementById('spreadLabel'), + spreadHint: document.getElementById('spreadHint'), spreadProgress: document.getElementById('spreadProgress'), spreadProgressFill: document.getElementById('spreadProgressFill'), spreadProgressText: document.getElementById('spreadProgressText'), spreadResults: document.getElementById('spreadResults'), spreadHighCount: document.getElementById('spreadHighCount'), spreadAvgCV: document.getElementById('spreadAvgCV'), + aiKeySummary: document.getElementById('aiKeySummary'), // ProScan cloud auth + export authForm: document.getElementById('authForm'), authEmail: document.getElementById('authEmail'), @@ -81,131 +114,189 @@ const elements = { authNotice: document.getElementById('authNotice'), authResetBtn: document.getElementById('authResetBtn'), authSignUpLink: document.getElementById('authSignUpLink'), + authSummarySub: document.getElementById('authSummarySub'), authSync: document.getElementById('authSync'), exportToProScanBtn: document.getElementById('exportToProScanBtn') }; +const fmt = (n) => Number(n || 0).toLocaleString('en-US'); +const plural = (n, one, many = one + 's') => `${fmt(n)} ${n === 1 ? one : many}`; + /** - * Update the dashboard stats display with current results. - * Calculates average price and rating using the Analyzer module - * and triggers the insights preview update. + * Update the stats in the last-scrape card: count, median price and average + * rating, the same figures the dock shows. * * @param {Object[]} results - Array of product objects */ function updateStats(results) { currentResults = results; - elements.itemCount.textContent = results.length; - - if (results.length > 0) { - const prices = results.map(r => Analyzer.parsePrice(r.price)).filter(p => p > 0); - const ratings = results.map(r => Analyzer.parseRating(r.rating)).filter(r => r > 0); - - if (prices.length > 0) { - const avgPrice = (prices.reduce((a, b) => a + b, 0) / prices.length).toFixed(2); - elements.avgPrice.textContent = '$' + avgPrice; - } - - if (ratings.length > 0) { - const avgRating = (ratings.reduce((a, b) => a + b, 0) / ratings.length).toFixed(1); - elements.avgRating.textContent = avgRating; - } + elements.itemCount.textContent = fmt(results.length); + elements.itemWord.textContent = results.length === 1 ? 'product' : 'products'; + elements.avgPrice.textContent = '-'; + elements.avgRating.textContent = '-'; + if (results.length === 0) return; - updateInsightsPreview(results); + const cents = results + .map(r => (Number.isInteger(r.priceCents) ? r.priceCents : Math.round(Analyzer.parsePrice(r.price) * 100))) + .filter(c => c > 0) + .sort((a, b) => a - b); + const ratings = results.map(r => Analyzer.parseRating(r.rating)).filter(r => r > 0); + if (cents.length > 0) { + const mid = Math.floor(cents.length / 2); + const median = cents.length % 2 ? cents[mid] : Math.round((cents[mid - 1] + cents[mid]) / 2); + elements.avgPrice.textContent = '$' + (median / 100).toFixed(2); } -} - -/** - * Show the top insight badge in the popup. - * Requires at least 5 products for meaningful analysis. - * Prioritizes high-priority insights (opportunities) over informational ones. - * - * @param {Object[]} results - Array of product objects - */ -function updateInsightsPreview(results) { - if (results.length < 5) return; - - const insights = Analyzer.generateInsights(results); - const topInsight = insights.find(i => i.priority === 'high') || insights[0]; - - if (topInsight) { - elements.insightText.textContent = topInsight.title; - elements.insightBadge.className = 'insight-badge ' + (topInsight.type === 'opportunity' ? 'opportunity' : ''); - elements.insightsPreview.classList.remove('hidden'); + if (ratings.length > 0) { + elements.avgRating.textContent = (ratings.reduce((a, b) => a + b, 0) / ratings.length).toFixed(1); } } /** - * Update the status bar message with an icon and styling. + * Show a status line under the tab description, or hide it with ''. * * @param {string} message - Status message text * @param {'info'|'success'|'error'|'warning'} [type='info'] - Status type for styling */ function updateStatus(message, type = 'info') { - elements.status.innerHTML = ` ${message}`; - elements.status.className = 'status ' + type; + elements.status.textContent = message || ''; + elements.status.className = 'status ' + type + (message ? '' : ' hidden'); } /** - * Map status type to Font Awesome icon name. - * - * @param {string} type - Status type - * @returns {string} Font Awesome icon identifier - */ -function getStatusIcon(type) { - const icons = { - info: 'info-circle', - success: 'check-circle', - error: 'exclamation-circle', - warning: 'exclamation-triangle' - }; - return icons[type] || 'info-circle'; -} - -/** - * Toggle the UI between scraping and idle states. - * Updates the action button text/style and shows/hides export buttons. + * Toggle the main button between Scrape and Stop. * * @param {boolean} isActive - Whether scraping is in progress */ function setScrapingState(isActive) { isScrapingActive = isActive; - + elements.actionButton.classList.toggle('stop', isActive); if (isActive) { - elements.actionButton.innerHTML = ' Stop Scraping'; - elements.actionButton.classList.add('stop'); - elements.exportButtons.classList.add('hidden'); + const n = currentRun ? currentRun.itemCount || 0 : currentResults.length; + setButton(elements.actionButton, 'stop', `Stop and keep ${plural(n, 'product')}`); } else { - elements.actionButton.innerHTML = ' Start Scraping'; - elements.actionButton.classList.remove('stop'); - if (currentResults.length > 0) { - elements.exportButtons.classList.remove('hidden'); - } + setButton(elements.actionButton, 'play', scrapeLabel()); } + elements.exportButtons.classList.toggle('hidden', isActive || currentResults.length === 0); } +const DOWNLOAD_LABELS = { downloadExcel: 'Excel', downloadCSV: 'CSV', downloadJSON: 'JSON' }; + /** - * Toggle loading spinner on a download button during export generation. + * Toggle loading state on a download button during export generation. * * @param {HTMLButtonElement} button - The download button element - * @param {boolean} loading - True to show spinner, false to restore original content + * @param {boolean} loading - True while the file is made */ function setDownloadLoading(button, loading) { - if (loading) { - button.innerHTML = ' Preparing...'; - button.disabled = true; + button.disabled = loading; + if (loading) setButton(button, 'spin', 'Making'); + else setButton(button, button.id === 'downloadExcel' ? 'down' : null, DOWNLOAD_LABELS[button.id]); +} + +/** The pages strip: saved pages, the one in progress, and hatched pages a store never had. */ +function drawTicks(run) { + const box = elements.lastTicks; + box.textContent = ''; + if (!run || !run.maxPages) return; + const max = run.maxPages; + const n = Math.min(max, 20); + const per = max / n; + const done = run.page || 0; + const live = Run.isActive(run); + const early = !live && run.reason === 'complete' && done < max; + for (let i = 0; i < n; i++) { + const from = i * per; + const to = (i + 1) * per; + const tick = document.createElement('i'); + if (done >= to - 1e-9) tick.className = 'done'; + else if (live && done >= from - 1e-9) tick.className = 'now'; + else if (!live && done > from) tick.className = 'done'; + else if (early) tick.className = 'skip'; + box.appendChild(tick); + } +} + +/** What a run scraped, in words: a storefront or a search. */ +function sourceLabel(run) { + const src = (run && run.source) || {}; + if (src.type === 'storefront') return src.name || (src.sellerId ? `storefront ${src.sellerId}` : 'a storefront'); + return src.keyword ? `search "${src.keyword}"` : 'search results'; +} + +function clock(ms) { + try { return new Date(ms).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } catch (e) { return ''; } +} + +/** The last-scrape card: count, source, pages strip, how it ended. */ +function renderLast(run) { + const has = !!run && (currentResults.length > 0 || Run.isActive(run)); + elements.lastScrape.classList.toggle('hidden', !has); + if (!has) return; + elements.lastSource.textContent = 'from ' + sourceLabel(run); + elements.lastWhen.textContent = Run.isActive(run) ? 'Running now' : run.finishedAt ? clock(run.finishedAt) : ''; + drawTicks(run); + let line = ''; + if (Run.isActive(run)) { + line = `Page ${fmt(Math.min((run.page || 0) + 1, run.maxPages))} of ${fmt(run.maxPages)}`; + } else if (run.reason === 'complete' && run.maxPages) { + line = run.page < run.maxPages + ? `Page ${fmt(run.page)} was the last one, so the run ended early.` + : `Stopped at the ${fmt(run.maxPages)}-page limit.`; + } else if (run.reason === 'stopped') { + line = `You stopped it after page ${fmt(run.page)}.`; + } + elements.lastLine.textContent = line; + elements.lastLine.classList.toggle('hidden', !line); +} + +/** The fallback button's name, the same one the dock uses for this tab. */ +function scrapeLabel() { + const where = currentTab && currentTab.where ? currentTab.where : {}; + if (where.kind === 'storefront') return 'Scrape this storefront'; + if (where.kind === 'search') return 'Scrape this search'; + return 'Scrape this page'; +} + +/** The top of the popup: what the active tab is and whether Scrape works there. */ +function renderHere() { + const run = currentRun; + const chip = elements.tabChip; + chip.className = 'chip'; + if (Run.isActive(run)) { + chip.classList.add('live'); + elements.tabChipText.textContent = 'Scraping now'; + elements.hereTitle.textContent = `Page ${fmt(Math.min((run.page || 0) + 1, run.maxPages))} of ${fmt(run.maxPages)}`; + elements.hereSub.textContent = `${plural(run.itemCount || 0, 'product')} saved so far from ${sourceLabel(run)}. Keep its tab open.`; + return; + } + const tab = currentTab; + const where = tab && tab.where ? tab.where : { kind: 'other' }; + const startable = !!tab && Run.STARTABLE.includes(tab.kind); + const hint = 'You can also press Scrape in the bottom-right corner of the page.'; + elements.actionButton.classList.toggle('muted', !startable); + if (!isScrapingActive) setButton(elements.actionButton, 'play', scrapeLabel()); + if (startable && where.kind === 'storefront') { + chip.classList.add('on'); + elements.tabChipText.textContent = 'This tab: seller storefront'; + elements.hereTitle.textContent = `${plural(tab.count, 'product')} on this page`; + elements.hereSub.textContent = `A seller's storefront. ${hint}`; + } else if (startable) { + chip.classList.add('on'); + elements.tabChipText.textContent = 'This tab: search results'; + elements.hereTitle.textContent = `${plural(tab.count, 'product')} on this page`; + elements.hereSub.textContent = (where.keyword ? `Search for "${where.keyword}". ` : '') + hint; + } else if (tab && tab.kind && (where.kind === 'search' || where.kind === 'storefront')) { + elements.tabChipText.textContent = 'This tab: cannot scrape yet'; + elements.hereTitle.textContent = 'Nothing to scrape here yet'; + elements.hereSub.textContent = Run.refusal(tab.kind); + } else if (where.kind === 'seller' && where.sellerId) { + elements.tabChipText.textContent = 'This tab: seller profile'; + elements.hereTitle.textContent = 'Open the storefront to scrape'; + elements.hereSub.textContent = 'The page has a ProScan button bottom-right that opens this seller\'s storefront.'; } else { - const icons = { - downloadExcel: 'file-excel', - downloadCSV: 'file-csv', - downloadJSON: 'file-code' - }; - const labels = { - downloadExcel: 'Excel', - downloadCSV: 'CSV', - downloadJSON: 'JSON' - }; - button.innerHTML = ` ${labels[button.id]}`; - button.disabled = false; + elements.tabChipText.textContent = tab && tab.url ? 'This tab: not a search' : 'This tab'; + elements.hereTitle.textContent = 'Open a search or a storefront'; + elements.hereSub.textContent = 'ProScan scrapes Amazon search results and seller storefronts, then saves them as Excel, CSV or JSON.'; } } @@ -217,27 +308,24 @@ function setDownloadLoading(button, loading) { */ function render(state) { const run = (state && state.run) || null; + currentRun = run; currentResults = (state && state.results) || []; currentSpread = (state && state.spread) || {}; isScrapingActive = Run.isActive(run); renderedPage = run ? run.page : -1; - elements.itemCount.textContent = currentResults.length; - elements.avgRating.textContent = '-'; - elements.avgPrice.textContent = '-'; - elements.insightsPreview.classList.add('hidden'); updateStats(currentResults); setScrapingState(isScrapingActive); + renderHere(); + renderLast(run); const line = Run.describe(run, currentResults.length); - if (line && (isScrapingActive || run.reason !== 'complete')) { - updateStatus(line.text, line.type); - } else if (currentResults.length > 0) { - updateStatus('Ready to download ' + currentResults.length + ' products', 'success'); - } + if (line && !isScrapingActive && run.reason !== 'complete') updateStatus(line.text, line.type); + else updateStatus(''); const showSpread = !isScrapingActive && currentResults.length > 0; elements.spreadButton.classList.toggle('hidden', !showSpread); + elements.spreadHint.textContent = `Checks other sellers' offers, about ${Math.max(1, Math.round(currentResults.length * 2.5 / 60))} min`; if (showSpread && Object.keys(currentSpread).length > 0) displaySpreadResults(); } @@ -261,16 +349,6 @@ async function warnIfNearlyFull() { } catch (e) { /* no estimate in this context */ } } -/** - * Initialize the popup UI from the worker's state. - * - * @async - */ -async function initializeUI() { - await refresh(); - await warnIfNearlyFull(); -} - /** The tab the popup was opened over, or null. */ function activeTab() { return new Promise(resolve => { @@ -294,6 +372,34 @@ function sendToTab(tabId, message) { const AMAZON_URL = /^https:\/\/([a-z0-9-]+\.)*amazon\.com\//i; +/** Looks at the active tab (PING) and redraws the top of the popup. */ +async function refreshTab() { + const tab = await activeTab(); + if (!tab || !AMAZON_URL.test(tab.url || '')) { + currentTab = tab ? { kind: null, count: 0, url: tab.url || '', where: { kind: 'other' } } : null; + } else { + const pong = await sendToTab(tab.id, { type: Msg.T.PING }); + currentTab = { + kind: pong && pong.ok ? pong.kind : null, + count: pong && pong.ok ? pong.count : 0, + url: tab.url, + where: PageKind.classify(tab.url) + }; + } + renderHere(); +} + +/** + * Initialize the popup UI from the worker's state. + * + * @async + */ +async function initializeUI() { + await refresh(); + await refreshTab(); + await warnIfNearlyFull(); +} + /** * Start a new scraping session. * @@ -320,6 +426,7 @@ async function startScraping() { offerReload(tab.id); return; } + refreshTab(); updateStatus((resp && (resp.message || resp.error)) || 'Could not start the run.', 'warning'); } @@ -369,6 +476,13 @@ async function stopScraping() { // --- Spread Analysis --- +function setSpreadButton(running) { + elements.spreadButton.classList.toggle('stop', running); + elements.spreadLabel.textContent = running ? 'Stop checking seller prices' : 'Compare seller prices'; + // Starting a run navigates the tab and would cut the analysis off. + elements.actionButton.disabled = running; +} + /** * Start the price spread analysis. * Sends a message to the offer-fetcher content script to begin @@ -381,13 +495,12 @@ async function startSpreadAnalysis() { } isSpreadAnalyzing = true; - elements.spreadButton.innerHTML = ' Stop Analysis'; - elements.spreadButton.classList.add('stop'); + setSpreadButton(true); elements.spreadProgress.classList.remove('hidden'); elements.spreadResults.classList.add('hidden'); elements.spreadProgressFill.style.width = '0%'; elements.spreadProgressText.textContent = `0/${currentResults.length}`; - updateStatus('Analyzing price spreads...', 'info'); + updateStatus('Checking seller prices. Keep the Amazon tab on its page until it finishes.', 'info'); chrome.tabs.query({ active: true, currentWindow: true }, tabs => { chrome.tabs.sendMessage(tabs[0].id, { type: Msg.T.START_SPREAD_ANALYSIS }); @@ -399,9 +512,8 @@ async function startSpreadAnalysis() { */ function stopSpreadAnalysis() { isSpreadAnalyzing = false; - elements.spreadButton.innerHTML = ' Analyze Price Spreads'; - elements.spreadButton.classList.remove('stop'); - updateStatus('Spread analysis stopped.', 'warning'); + setSpreadButton(false); + updateStatus('Seller price check stopped.', 'info'); chrome.tabs.query({ active: true, currentWindow: true }, tabs => { chrome.tabs.sendMessage(tabs[0].id, { type: Msg.T.STOP_SPREAD_ANALYSIS }); @@ -422,22 +534,16 @@ function displaySpreadResults() { elements.spreadResults.classList.remove('hidden'); elements.spreadProgress.classList.add('hidden'); - // Show insight if there are high-spread products if (summary.highSpreadCount > 0) { - updateStatus( - `Found ${summary.highSpreadCount} products with high price spread!`, - 'success' - ); + updateStatus(`${plural(summary.highSpreadCount, 'product')} with a wide price spread. They are marked in the Excel and CSV files.`, 'success'); } else if (summary.withSpreadData > 0) { - updateStatus( - `Analyzed ${summary.withSpreadData} products. No high spreads found.`, - 'info' - ); + updateStatus(`Checked ${plural(summary.withSpreadData, 'product')}. No wide spreads found.`, 'info'); } } // --- ProScan Cloud Auth + Export --- + /** * Send a message to the background service worker and resolve with its * response. The sync/auth handlers are async (return true), so responses may @@ -467,7 +573,8 @@ function sendToWorker(message) { function showSignedIn(user) { currentProScanUser = user; elements.authStatusEmail.textContent = user.email || user.displayName || 'Signed in'; - document.getElementById('authSummary').textContent = 'Syncing to dashboard'; + elements.authSummarySub.textContent = 'Signed in'; + elements.authSummarySub.classList.add('on'); elements.authForm.classList.add('hidden'); elements.authAccount.classList.remove('hidden'); elements.authError.classList.add('hidden'); @@ -488,12 +595,12 @@ function showSyncState(state) { let text = 'Scans sync to your ProScan dashboard automatically.'; if (pending > 0) { text = `${pending} page${pending === 1 ? '' : 's'} waiting to sync.`; - if (last && last.error) text += ' The last try failed; it will try again in a while, or now with Export to ProScan.'; + if (last && last.error) text += ' The last try failed; it will try again in a while, or now with Send to my ProScan dashboard.'; } else if (last && !last.error) { text = 'Everything is synced.'; } if (failed > 0) { - text += ` ${failed} page${failed === 1 ? '' : 's'} could not sync: ProScan refused ${failed === 1 ? 'it' : 'them'}. Export to ProScan tries again.`; + text += ` ${failed} page${failed === 1 ? '' : 's'} could not sync: ProScan refused ${failed === 1 ? 'it' : 'them'}. Send to my ProScan dashboard tries again.`; } elements.authSync.textContent = text; elements.authSync.classList.remove('hidden'); @@ -511,7 +618,8 @@ function showSignInForm(notice = null) { ? 'Your session expired. Sign in again to keep syncing; your scans are kept.' : ''; elements.authNotice.classList.toggle('hidden', notice !== 'expired'); - document.getElementById('authSummary').textContent = 'Dashboard sync (optional)'; + elements.authSummarySub.textContent = 'Not signed in'; + elements.authSummarySub.classList.remove('on'); // An expired session is the one case worth unfolding the panel for. if (notice === 'expired') document.getElementById('authPanel').open = true; elements.authForm.classList.remove('hidden'); @@ -560,7 +668,7 @@ async function handleResetPassword() { */ function resetSignInButton() { elements.authSignInBtn.disabled = false; - elements.authSignInBtn.innerHTML = ' Sign in to ProScan'; + setButton(elements.authSignInBtn, null, 'Sign in to ProScan'); } /** @@ -601,7 +709,7 @@ async function handleSignIn() { } elements.authSignInBtn.disabled = true; - elements.authSignInBtn.innerHTML = ' Signing in…'; + setButton(elements.authSignInBtn, 'spin', 'Signing in'); const response = await sendToWorker({ type: Msg.T.PROSCAN_SIGN_IN, email, password }); @@ -648,8 +756,8 @@ async function handleExportToProScan() { isExporting = true; elements.exportToProScanBtn.disabled = true; - elements.exportToProScanBtn.innerHTML = ' Exporting…'; - updateStatus('Exporting to ProScan…', 'info'); + setButton(elements.exportToProScanBtn, 'spin', 'Sending'); + updateStatus('Sending to your ProScan dashboard.', 'info'); const response = await sendToWorker({ type: Msg.T.PROSCAN_EXPORT }); @@ -673,7 +781,7 @@ async function handleExportToProScan() { isExporting = false; elements.exportToProScanBtn.disabled = false; - elements.exportToProScanBtn.innerHTML = ' Export to ProScan'; + setButton(elements.exportToProScanBtn, null, 'Send to my ProScan dashboard'); } // --- Event Listeners --- @@ -714,92 +822,37 @@ elements.actionButton.addEventListener('click', async () => { console.error('[ProScan] Start or stop failed:', err && err.message); updateStatus('Something went wrong. Close and reopen the popup, then try again.', 'error'); } finally { - elements.actionButton.disabled = false; + elements.actionButton.disabled = isSpreadAnalyzing; } }); -// Excel export with full analytics report -elements.downloadExcel.addEventListener('click', async () => { - if (currentResults.length === 0) { - updateStatus('No results to download!', 'warning'); - return; - } - - setDownloadLoading(elements.downloadExcel, true); - updateStatus('Generating Excel report...', 'info'); - - try { - const analysisReport = Analyzer.generateFullReport(currentResults); - const { blob, filename } = Exporter.exportToExcel(currentResults, analysisReport); - - Exporter.triggerDownload(blob, filename, (downloadId) => { - if (downloadId) { - updateStatus('Download started!', 'success'); - } else { - updateStatus('Download failed. Try again.', 'error'); - } - setDownloadLoading(elements.downloadExcel, false); - }); - } catch (error) { - console.error('Excel export error:', error); - updateStatus('Error generating Excel file', 'error'); - setDownloadLoading(elements.downloadExcel, false); - } -}); - -// CSV export -elements.downloadCSV.addEventListener('click', async () => { +/** + * Makes the file for `format` with Exporter.build (the same code the + * worker uses for the page) and opens the save dialog. + */ +function downloadFile(button, format) { if (currentResults.length === 0) { - updateStatus('No results to download!', 'warning'); + updateStatus('There is nothing to download yet.', 'warning'); return; } - - setDownloadLoading(elements.downloadCSV, true); - + setDownloadLoading(button, true); try { - const { blob, filename } = Exporter.exportToCSV(currentResults); - + const { blob, filename } = Exporter.build(format, currentResults, currentSpread); Exporter.triggerDownload(blob, filename, (downloadId) => { - if (downloadId) { - updateStatus('CSV download started!', 'success'); - } else { - updateStatus('Download failed. Try again.', 'error'); - } - setDownloadLoading(elements.downloadCSV, false); + if (downloadId) updateStatus(`${DOWNLOAD_LABELS[button.id]} download started.`, 'success'); + else updateStatus('The download did not start. Try again.', 'error'); + setDownloadLoading(button, false); }); } catch (error) { - console.error('CSV export error:', error); - updateStatus('Error generating CSV file', 'error'); - setDownloadLoading(elements.downloadCSV, false); - } -}); - -// JSON export with analytics -elements.downloadJSON.addEventListener('click', async () => { - if (currentResults.length === 0) { - updateStatus('No results to download!', 'warning'); - return; + console.error('[ProScan] Export failed:', error); + updateStatus(`ProScan could not make the ${DOWNLOAD_LABELS[button.id]} file.`, 'error'); + setDownloadLoading(button, false); } +} - setDownloadLoading(elements.downloadJSON, true); - - try { - const { blob, filename } = Exporter.exportToJSON(currentResults, true); - - Exporter.triggerDownload(blob, filename, (downloadId) => { - if (downloadId) { - updateStatus('JSON download started!', 'success'); - } else { - updateStatus('Download failed. Try again.', 'error'); - } - setDownloadLoading(elements.downloadJSON, false); - }); - } catch (error) { - console.error('JSON export error:', error); - updateStatus('Error generating JSON file', 'error'); - setDownloadLoading(elements.downloadJSON, false); - } -}); +elements.downloadExcel.addEventListener('click', () => downloadFile(elements.downloadExcel, 'xlsx')); +elements.downloadCSV.addEventListener('click', () => downloadFile(elements.downloadCSV, 'csv')); +elements.downloadJSON.addEventListener('click', () => downloadFile(elements.downloadJSON, 'json')); /** * Messages from the offer fetcher in the tab: @@ -814,8 +867,7 @@ chrome.runtime.onMessage.addListener((request) => { elements.spreadProgressText.textContent = `${request.current}/${request.total}`; } else if (request.type === Msg.T.SPREAD_ANALYSIS_COMPLETE) { isSpreadAnalyzing = false; - elements.spreadButton.innerHTML = ' Analyze Price Spreads'; - elements.spreadButton.classList.remove('stop'); + setSpreadButton(false); refresh(); } return false; @@ -824,18 +876,45 @@ chrome.runtime.onMessage.addListener((request) => { // The worker writes the run record to session storage on every change, so // the view follows it: a new page, the end of the run, a closed tab. chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes.geminiApiKey) showKeySummary(!!changes.geminiApiKey.newValue); if (area !== 'session' || !changes[Run.KEY]) return; const run = changes[Run.KEY].newValue; if (!run) return; if (!Run.isActive(run) || run.page !== renderedPage || !isScrapingActive) { refresh(); } else { - elements.itemCount.textContent = run.itemCount || 0; + currentRun = run; + renderHere(); + setScrapingState(true); } }); +/** The one-line state under "AI chat settings". */ +function showKeySummary(set) { + elements.aiKeySummary.textContent = set ? 'Gemini key added' : 'No key yet'; + elements.aiKeySummary.classList.toggle('on', set); +} + +/** popup.html#key or #sync, opened from the dock: unfold that setting. */ +function openFromHash() { + const hash = (location.hash || '').slice(1); + if (!hash) return; + document.body.classList.add('in-tab'); + const panel = document.getElementById(hash === 'sync' ? 'authPanel' : 'aiSettings'); + if (!panel || panel.classList.contains('hidden')) return; + panel.open = true; + const field = hash === 'sync' ? elements.authEmail : document.getElementById('geminiKeyInput'); + if (field) field.focus(); + panel.scrollIntoView({ block: 'nearest' }); +} + +// For scripts that aim the popup at a tab after it opened (the screenshot harness). +window.ProScanPopup = { refreshTab, refresh }; + // Initialize on DOM load document.addEventListener('DOMContentLoaded', () => { + try { document.getElementById('version').textContent = chrome.runtime.getManifest().version; } catch (e) { /* no manifest */ } + chrome.storage.local.get('geminiApiKey').then((d) => showKeySummary(!!(d && d.geminiApiKey)), () => {}); initializeUI(); - initializeAuthUI(); + initializeAuthUI().then(openFromHash, openFromHash); }); diff --git a/scripts/background/download.js b/scripts/background/download.js new file mode 100644 index 0000000..f7d8593 --- /dev/null +++ b/scripts/background/download.js @@ -0,0 +1,56 @@ +/** + * @fileoverview Downloads asked for from the page (DOWNLOAD {format}). + * + * The file is made by the same Exporter.build the popup uses. A content + * script has no chrome.downloads and a service worker has no + * URL.createObjectURL, so the worker hands the file to chrome.downloads as + * a data: URL, which keeps the save-as prompt. Chrome drops a URL over 2 MB + * on its way between processes, so a larger file goes back to the page as + * base64 and the dock saves it there with a download link. + * + * @module Download + */ + +const FORMATS = { + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + csv: 'text/csv', + json: 'application/json' +}; + +/** Largest base64 body sent to chrome.downloads as a data: URL. */ +const DATA_URL_MAX = 1.5 * 1024 * 1024; + +/** Bytes to base64, in chunks so a large file does not overflow the call stack. */ +function toBase64(bytes) { + let bin = ''; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); + } + return btoa(bin); +} + +/** + * Makes the file for `format` from the latest run and delivers it. + * + * @param {Object} deps + * @param {Object} deps.Exporter - scripts/modules/exporter.js + * @param {function(): Promise<{results: Object[], spread: Object}>} deps.getState + * @param {function(Object): Promise} deps.download - chrome.downloads.download as a promise + * @param {string} format - xlsx, csv or json + * @returns {Promise} {ok, via: 'downloads', filename} or + * {ok, via: 'page', filename, mime, base64}, or {error, message} + */ +async function deliver({ Exporter, getState, download, max = DATA_URL_MAX }, format) { + const mime = FORMATS[format]; + if (!mime) return { error: 'format', message: 'ProScan can save Excel, CSV or JSON.' }; + const { results, spread } = await getState(); + if (!results || results.length === 0) return { error: 'empty', message: 'There is nothing to download yet.' }; + const { blob, filename } = Exporter.build(format, results, spread); + const base64 = toBase64(new Uint8Array(await blob.arrayBuffer())); + if (base64.length > max) return { ok: true, via: 'page', filename, mime, base64 }; + await download({ url: `data:${mime};base64,${base64}`, filename, saveAs: true }); + return { ok: true, via: 'downloads', filename }; +} + +module.exports = { deliver, toBase64, FORMATS, DATA_URL_MAX }; diff --git a/scripts/background/engine.js b/scripts/background/engine.js index f366113..fb57f1f 100644 --- a/scripts/background/engine.js +++ b/scripts/background/engine.js @@ -93,6 +93,55 @@ function prevFor(snap, uid) { return !snap.uid || snap.uid === uid ? snap : null; } +/** + * A few numbers about a finished run for the dock: median price, average + * rating and the share of sponsored products. Nulls when nothing has one. + */ +function summarize(results) { + const rows = Array.isArray(results) ? results : []; + const cents = rows.map(r => r && r.priceCents).filter(c => Number.isInteger(c) && c > 0).sort((a, b) => a - b); + let medianCents = null; + if (cents.length) { + const mid = Math.floor(cents.length / 2); + medianCents = cents.length % 2 ? cents[mid] : Math.round((cents[mid - 1] + cents[mid]) / 2); + } + const ratings = rows.map(r => r && r.rating).filter(v => typeof v === 'number' && v > 0); + const avgRating = ratings.length ? Math.round((ratings.reduce((a, b) => a + b, 0) / ratings.length) * 10) / 10 : null; + const sponsoredPct = rows.length ? Math.round((rows.filter(r => r && r.sponsored).length / rows.length) * 100) : null; + return { medianCents, avgRating, sponsoredPct }; +} + +/** + * A storefront's name from its tab title ("Amazon.com: Northfield Goods"), + * or null. Kept on the run for labels only; it never goes to the cloud. + */ +function storeNameOf(title) { + const t = String(title == null ? '' : title) + .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/^\s*Amazon\.com\s*[:|-]?\s*/i, '') + .trim(); + return t && t.length <= 60 && !/^amazon/i.test(t) ? t : null; +} + +/** The run as the page may see it: no product rows, no tab ids but its own. */ +function brief(run, tabId) { + if (!run) return null; + const src = run.source || {}; + return { + runId: run.runId || null, + state: Run.stateOf(run), + reason: run.reason || null, + page: run.page || 0, + maxPages: run.maxPages || Run.DEFAULT_MAX_PAGES, + itemCount: run.itemCount || 0, + startedAt: run.startedAt || null, + finishedAt: run.finishedAt || null, + source: { type: src.type || null, sellerId: src.sellerId || null, keyword: src.keyword || null, name: src.name || null }, + thisTab: typeof tabId === 'number' && run.tabId === tabId + }; +} + /** * @param {Object} deps * @param {Object} deps.chrome - chrome.* (storage.session, storage.local, tabs) @@ -251,10 +300,11 @@ function createEngine({ // One id for the run, here and in the cloud: {sourceId}_{startMs}. const found = Schema.sourceOf(pong.url); const sourceId = Schema.sourceIdOf(found); + const name = found.type === 'storefront' ? storeNameOf(pong.title) : null; const runId = Schema.runIdOf(sourceId, t); let run = Run.create({ runId, tabId, maxPages: settings.maxPages, now: t, - source: { ...found, sourceId, startedAt: new Date(t).toISOString() } + source: { ...found, sourceId, name, startedAt: new Date(t).toISOString() } }); run = { ...run, sourceId, dayKey: Schema.dayKeyOf(t, new Date(t).getTimezoneOffset()) }; await saveRun(run); @@ -422,6 +472,9 @@ function createEngine({ return { ok: true, next: 'end', reason: ending }; } schedule(next.navAt - t); + sendToTab(next.tabId, { + type: Msg.T.RUN_PROGRESS, runId, page: next.page, itemCount: next.itemCount, maxPages: next.maxPages + }); return { ok: true, next: 'wait' }; }); } @@ -577,10 +630,56 @@ function createEngine({ }; } + /** Merges `patch` into settings: maxPages (clamped) and suggest (a boolean). */ + async function saveSettings(patch = {}) { + const local = await chrome.storage.local.get('settings'); + const settings = { ...((local && local.settings) || {}) }; + if (patch.maxPages !== undefined) settings.maxPages = Run.clampMaxPages(patch.maxPages); + if (patch.suggest !== undefined) settings.suggest = !!patch.suggest; + await chrome.storage.local.set({ settings }); + return { ok: true, settings: { maxPages: Run.clampMaxPages(settings.maxPages), suggest: settings.suggest !== false } }; + } + + /** + * START_RUN_HERE from the dock: a run in the sender's own tab. A page + * count in the message becomes the saved setting first. + */ + async function startHere({ maxPages } = {}, sender) { + const tabId = sender && sender.tab ? sender.tab.id : null; + if (typeof tabId !== 'number') return { error: 'refused', message: Run.refusal('unknown') }; + if (maxPages !== undefined && maxPages !== null) await saveSettings({ maxPages }); + return start({ tabId }); + } + + /** + * RUN_STATUS for the dock: the latest run in brief, whether it belongs + * to the asking tab, a summary once it has ended, and the settings the + * dock shows. Never the products, the key or the account's email. + */ + function status(sender) { + const tabId = sender && sender.tab ? sender.tab.id : null; + return serial(async () => { + const local = await chrome.storage.local.get(['settings', 'geminiApiKey', 'account']); + const settings = (local && local.settings) || {}; + const base = { + settings: { maxPages: Run.clampMaxPages(settings.maxPages), suggest: settings.suggest !== false }, + hasKey: typeof local.geminiApiKey === 'string' && local.geminiApiKey.trim() !== '', + signedIn: !!(local.account && local.account.uid), + cloudSync: !!flags.CLOUD_SYNC + }; + const live = await liveRun(); + // While a run goes, the dock asks often: no product reads then. + if (Run.isActive(live)) return { ...base, run: brief(live, tabId), count: live.itemCount || 0 }; + const { run, results, spread } = await latest(); + const spreadCount = Object.values(spread || {}).filter(Boolean).length; + return { ...base, run: brief(run, tabId), count: results.length, summary: summarize(results), spreadCount }; + }); + } + return { start, stop, pageReady, pageResult, heartbeat, tick, tabRemoved, tabUpdated, recover, - getState, getResults, spreadResult, chatData, db + getState, getResults, spreadResult, chatData, db, startHere, status, saveSettings }; } -module.exports = { createEngine, foldPage, durable, prevFor, PAGE_TIMEOUT_MS, KEEP_RUNS }; +module.exports = { createEngine, foldPage, durable, prevFor, summarize, brief, storeNameOf, PAGE_TIMEOUT_MS, KEEP_RUNS }; diff --git a/scripts/background/service-worker.js b/scripts/background/service-worker.js index 936f601..0a3fc73 100644 --- a/scripts/background/service-worker.js +++ b/scripts/background/service-worker.js @@ -14,6 +14,16 @@ import Run from '../lib/run.js'; import Flags from '../lib/flags.js'; import Migrate from '../lib/migrate.js'; import Msg from '../lib/messages.js'; +import XLSX from '../../libs/xlsx.full.min.js'; +import Analyzer from '../modules/analyzer.js'; +import SpreadAnalyzer from '../modules/spread-analyzer.js'; +import Exporter from '../modules/exporter.js'; +import Download from './download.js'; + +// exporter.js reads these as globals, as it does in the popup. +globalThis.XLSX = XLSX; +globalThis.Analyzer = Analyzer; +globalThis.SpreadAnalyzer = SpreadAnalyzer; /** * @fileoverview Background Service Worker @@ -52,6 +62,31 @@ router.on(Msg.T.PAGE_READY, (m, sender) => engine.pageReady(m, sender)); router.on(Msg.T.PAGE_RESULT, (m, sender) => thenFlush(engine.pageResult(m, sender))); router.on(Msg.T.HEARTBEAT, (m, sender) => engine.heartbeat(m, sender)); +// The dock on the page. The run is always the sender's own tab. +router.on(Msg.T.START_RUN_HERE, (m, sender) => engine.startHere({ maxPages: m.maxPages }, sender)); +router.on(Msg.T.STOP_RUN_HERE, () => thenFlush(engine.stop())); +router.on(Msg.T.RUN_STATUS, (m, sender) => engine.status(sender)); +router.on(Msg.T.SAVE_SETTINGS, (m) => engine.saveSettings({ maxPages: m.maxPages, suggest: m.suggest })); +router.on(Msg.T.DOWNLOAD, (m) => + Download.deliver({ + Exporter, + getState: () => engine.getState(), + download: (opts) => chrome.downloads.download(opts), + }, m.format).catch((err) => { + console.warn('[ProScan] Download failed:', err && err.message); + return { error: 'failed', message: 'ProScan could not make the file. Try again.' }; + })); +// Keys and passwords are typed on ProScan's own page, never on Amazon's. +router.on(Msg.T.OPEN_SETTINGS, async (m, sender) => { + const section = m.section === 'sync' ? 'sync' : 'key'; + const tab = sender.tab || {}; + const opts = { url: chrome.runtime.getURL(`popup/popup.html#${section}`) }; + if (typeof tab.index === 'number') opts.index = tab.index + 1; + if (typeof tab.id === 'number') opts.openerTabId = tab.id; + await chrome.tabs.create(opts); + return { ok: true }; +}); + // Spread analysis router.on(Msg.T.GET_RESULTS, () => engine.getResults()); router.on(Msg.T.SPREAD_RESULT, (m) => engine.spreadResult(m)); diff --git a/scripts/content/chatbot.js b/scripts/content/chatbot.js deleted file mode 100644 index d7938ee..0000000 --- a/scripts/content/chatbot.js +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @fileoverview Floating AI Chatbot Widget (Shadow DOM) - * - * Injects a floating chat widget on Amazon product pages that lets users - * ask natural-language questions about scraped products. Uses Shadow DOM - * to fully isolate widget styles from Amazon's CSS, preventing conflicts. - * - * Architecture: - * - IIFE wraps the entire module for scope isolation - * - Shadow DOM (closed mode) prevents Amazon CSS interference - * - Communicates with service-worker.js via chrome.runtime.sendMessage - * - The worker holds the Gemini key and the scanned products; this script - * only sends the question and a short history, and renders answers as text - * - * Widget Components: - * - Toggle button: circular icon (bottom-right corner, max z-index) - * - Chat panel: header with product badge, scrollable messages, text input - * - Message bubbles: AI (green), User (blue), Error (red), Loading (italic) - * - * @module Chatbot - */ - -(function () { - // Only activate on pages with product listings - if (!document.querySelector('.s-result-item[data-asin]')) return; - - /** @type {boolean} Whether the chat panel is currently open */ - let isOpen = false; - - /** @type {ShadowRoot} Closed shadow root for style isolation */ - let shadow; - - /** @type {HTMLElement} Messages container element */ - let messagesEl; - - /** @type {HTMLInputElement} Text input element */ - let inputEl; - - /** @type {HTMLButtonElement} Send button element */ - let sendBtn; - - /** @type {HTMLElement} Product count badge element */ - let badgeEl; - - /** @type {HTMLElement} First AI bubble, rewritten from the chat status */ - let greetingEl; - - /** @type {{role: string, text: string}[]} Recent turns, sent for follow-ups */ - const history = []; - const MAX_HISTORY = 6; - - const NO_KEY_TEXT = 'AI chat needs your own Gemini API key. Open the ProScan popup and paste it under "AI chat settings".'; - - // Asset URLs resolved via chrome.runtime for extension context - const LOGO_URL = chrome.runtime.getURL('assets/icons/icon128.png'); - const ICON_SEND = ''; - - /** - * Initialize the chatbot widget. - * Creates the Shadow DOM host, loads styles, builds the HTML structure, - * and attaches event listeners for toggle, close, send, and keyboard input. - */ - function init() { - const host = document.createElement('div'); - host.id = 'proscan-chatbot-host'; - document.body.appendChild(host); - - shadow = host.attachShadow({ mode: 'closed' }); - - // Load stylesheet into Shadow DOM - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = chrome.runtime.getURL('styles/chatbot.css'); - shadow.appendChild(link); - - // Build widget HTML structure - const wrapper = document.createElement('div'); - wrapper.innerHTML = ` - -
-
- ProScan AI - 0 products - -
-
-
-
-
- - -
-
- `; - shadow.appendChild(wrapper); - - // Cache DOM references within Shadow DOM - const panel = shadow.querySelector('.proscan-panel'); - const toggle = shadow.querySelector('.proscan-toggle'); - const closeBtn = shadow.querySelector('.proscan-close'); - messagesEl = shadow.getElementById('ps-messages'); - inputEl = shadow.getElementById('ps-input'); - sendBtn = shadow.getElementById('ps-send'); - badgeEl = shadow.getElementById('ps-badge'); - greetingEl = shadow.getElementById('ps-greeting'); - - // Toggle panel open/close - toggle.addEventListener('click', () => { - isOpen = !isOpen; - panel.classList.toggle('open', isOpen); - if (isOpen) { - inputEl.focus(); - refreshStatus(); - } - }); - - closeBtn.addEventListener('click', () => { - isOpen = false; - panel.classList.remove('open'); - }); - - // Send message on button click or Enter key - sendBtn.addEventListener('click', sendMessage); - inputEl.addEventListener('keydown', (e) => { - if (e.key === 'Enter') sendMessage(); - }); - - refreshStatus(); - } - - /** True while this content script still belongs to a loaded extension. */ - function alive() { - try { - return !!(chrome.runtime && chrome.runtime.id); - } catch (e) { - return false; - } - } - - function unreachableText() { - return alive() - ? 'Could not reach ProScan. Reload this page and try again.' - : 'ProScan was updated. Reload this page to use the chat.'; - } - - function plural(n, word) { - return n + ' ' + word + (n !== 1 ? 's' : ''); - } - - /** - * Ask the worker whether a key is set and which run the chat covers, - * then update the badge and greeting to match. - */ - async function refreshStatus() { - let st = null; - try { - st = await chrome.runtime.sendMessage({ type: 'CHAT_STATUS' }); - } catch (e) { - st = null; - } - if (!st) { - greetingEl.textContent = unreachableText(); - return; - } - const count = st.productCount || 0; - badgeEl.textContent = plural(count, 'product'); - if (!st.hasKey) { - greetingEl.textContent = NO_KEY_TEXT; - } else if (!count) { - greetingEl.textContent = 'No scanned products yet. Start a scan from the ProScan popup, then ask me about it.'; - } else { - const parts = [st.source, st.pages ? plural(st.pages, 'page') : ''].filter(Boolean); - const from = parts.length ? ' (' + parts.join(', ') + ')' : ''; - greetingEl.textContent = 'Ask me about the ' + plural(count, 'product') + ' from your last ProScan scan' + from + '.'; - } - } - - /** - * Append a message bubble to the chat messages area. - * - * @param {string} text - Message text content - * @param {string} type - CSS class(es) for bubble styling ('user', 'ai', 'ai loading', 'ai error') - * @returns {HTMLElement} The created bubble element (useful for updating loading messages) - */ - function addBubble(text, type) { - const bubble = document.createElement('div'); - bubble.className = 'proscan-bubble ' + type; - bubble.textContent = text; - messagesEl.appendChild(bubble); - messagesEl.scrollTop = messagesEl.scrollHeight; - return bubble; - } - - /** - * Send the user's question to the Gemini API via the service worker. - * - * Flow: - * 1. Read user input and display as user bubble - * 2. Show "Thinking..." loading bubble - * 3. Send CHAT_MESSAGE to the service worker with the question and history - * 4. The worker answers from the current run, or returns an error - * 5. Update loading bubble with AI response (or error) - * 6. Re-enable input for next question - * - * @async - */ - async function sendMessage() { - const question = inputEl.value.trim(); - if (!question) return; - - addBubble(question, 'user'); - inputEl.value = ''; - sendBtn.disabled = true; - - const loadingBubble = addBubble('Thinking...', 'ai loading'); - - try { - // The worker adds the key and the current run's products - const response = await chrome.runtime.sendMessage({ - type: 'CHAT_MESSAGE', - question: question, - history: history.slice(-MAX_HISTORY) - }); - - if (response && response.answer) { - // Model output is text, never HTML - loadingBubble.textContent = response.answer; - loadingBubble.className = 'proscan-bubble ai'; - history.push({ role: 'user', text: question }, { role: 'model', text: response.answer }); - history.splice(0, Math.max(0, history.length - MAX_HISTORY)); - } else if (response && response.error) { - loadingBubble.textContent = response.error; - loadingBubble.className = 'proscan-bubble ai error'; - } else { - loadingBubble.textContent = 'No response received.'; - loadingBubble.className = 'proscan-bubble ai error'; - } - } catch (err) { - loadingBubble.textContent = unreachableText(); - loadingBubble.className = 'proscan-bubble ai error'; - } - - sendBtn.disabled = false; - inputEl.focus(); - } - - // Initialize when DOM is ready - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/scripts/content/dock-styles.js b/scripts/content/dock-styles.js new file mode 100644 index 0000000..7884a89 --- /dev/null +++ b/scripts/content/dock-styles.js @@ -0,0 +1,254 @@ +/** + * @fileoverview Styles for the on-page dock (scripts/content/dock.js). + * + * Kept as a string so the dock can adopt it as a constructed stylesheet + * inside its closed shadow root: no web-accessible file, no request, and + * the page's CSP does not apply. System fonts only. + * + * @module DockStyles + */ + +const DOCK_CSS = ` +:host { all: initial; } +.dock { + --font: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif; + --surface: #ffffff; + --sunken: #f2f4f6; + --ink: #1c232b; + --muted: #5a6570; + --line: #e1e5e9; + --line-strong: #c9d0d7; + --primary: #2a343e; + --primary-hover: #1c242c; + --on-primary: #ffffff; + --soft: #e8ecef; + --soft-ink: #2a343e; + --ok: #1f7a4d; + --ok-soft: #e3f3ea; + --warn: #8a4b08; + --warn-soft: #fbf0e1; + --ring: #2a343e; + --shadow: 0 0 0 1px rgba(20,22,30,.05), 0 2px 4px rgba(20,22,30,.06), 0 18px 40px -10px rgba(20,22,30,.28); + position: fixed; right: 20px; bottom: 20px; z-index: 2147483647; + display: flex; flex-direction: column; align-items: flex-end; + font: 400 14px/1.45 var(--font); color: var(--ink); + -webkit-font-smoothing: antialiased; text-align: left; + font-variant-numeric: tabular-nums; +} +@media (prefers-color-scheme: dark) { + .dock { + --surface: #2a343e; --sunken: #333e49; --ink: #ffffff; --muted: #b3bcc6; --line: #3b4753; --line-strong: #4c5967; + --primary: #ffffff; --primary-hover: #e4e8ec; --on-primary: #2a343e; --soft: #36424e; --soft-ink: #ffffff; + --ok: #5cc493; --ok-soft: #1c3329; --warn: #f0b36b; --warn-soft: #3a2c1a; + --ring: #ffffff; + --shadow: 0 0 0 1px rgba(255,255,255,.07), 0 18px 40px -10px rgba(0,0,0,.65); + } +} +@media print { .dock { display: none; } } +* { box-sizing: border-box; } +[hidden] { display: none !important; } +button, input, a { font: inherit; color: inherit; } +button { background: none; border: 0; margin: 0; padding: 0; cursor: pointer; text-align: inherit; } +button:disabled { cursor: default; opacity: .55; } +a { text-decoration: none; } +:focus { outline: none; } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +svg { display: block; flex: none; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.num { font-variant-numeric: tabular-nums; } + +/* The mark: binoculars in a cobalt disc */ +.mark { width: 40px; height: 40px; border-radius: 50%; background: #2a343e url("data:image/png;base64,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") center / cover; box-shadow: 0 0 0 1px var(--line-strong); color: #ffffff; display: grid; place-items: center; flex: none; position: relative; } +/* The logo is the background; the drawn glyph stays in the DOM for the progress ring. */ +.mark > svg:first-child { visibility: hidden; } +.mark svg { width: 22px; height: 22px; } +.mark.small { width: 32px; height: 32px; } +.mark.small svg { width: 18px; height: 18px; } +.mark .badge { position: absolute; right: -3px; bottom: -3px; width: 18px; height: 18px; border-radius: 50%; background: var(--ok); color: var(--surface); display: grid; place-items: center; border: 2px solid var(--surface); } +.mark .badge svg { width: 10px; height: 10px; } +.ring { position: absolute; inset: -5px; width: 50px !important; height: 50px !important; transform: rotate(-90deg); } +.ring circle { fill: none; stroke-width: 3; } +.ring .track { stroke: var(--line); } +.ring .val { stroke: var(--primary); stroke-linecap: round; } + +/* ---------- Launcher ---------- */ +.launcher { + display: flex; align-items: center; gap: 6px; + background: var(--surface); border: 1px solid var(--line); border-radius: 999px; + box-shadow: var(--shadow); padding: 5px; + max-width: calc(100vw - 24px); +} +.launch-main { display: flex; align-items: center; gap: 10px; border-radius: 999px; padding-right: 8px; min-width: 0; } +.launch-main:hover .launch-text b { text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; } +.launch-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; } +.launch-text b { font-weight: 620; font-size: 14.5px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.launch-text span { font-size: 12.5px; color: var(--muted); white-space: nowrap; } +.launcher.plain { gap: 2px; padding: 4px; } +.launcher.plain .launch-main { padding: 0 10px 0 4px; gap: 8px; height: 36px; } +.launcher.plain .launch-main b { font-size: 13.5px; font-weight: 600; } +.launcher.plain .mark { width: 28px; height: 28px; } +.launcher.plain .mark svg { width: 16px; height: 16px; } +.launcher .sep { width: 1px; height: 20px; background: var(--line); margin: 0 2px; } +.icon-btn { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; color: var(--muted); flex: none; } +.icon-btn:hover { background: var(--sunken); color: var(--ink); } +.icon-btn svg { width: 16px; height: 16px; } +.pill { background: var(--primary); color: var(--on-primary); border-radius: 999px; padding: 0 16px; height: 38px; font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; flex: none; } +.pill:hover { background: var(--primary-hover); } +.pill svg { width: 13px; height: 13px; } +.pill.outline { background: var(--surface); color: var(--ink); border: 1px solid var(--line-strong); height: 34px; padding: 0 12px; font-size: 13px; } +.pill.outline:hover { background: var(--sunken); } +.pill.outline svg { width: 11px; height: 11px; } +@media (max-width: 1279px) { .launcher .launch-text.can-drop span { display: none; } } +@media (max-width: 520px) { .launcher .launch-text.can-drop { display: none; } .launcher .launch-main { padding-right: 0; } } + +/* ---------- Card ---------- */ +.card { + width: 376px; max-width: calc(100vw - 24px); max-height: calc(100vh - 40px); + background: var(--surface); border: 1px solid var(--line); border-radius: 20px; + box-shadow: var(--shadow); overflow: hidden; + display: flex; flex-direction: column; +} +.head { display: flex; align-items: center; gap: 8px; padding: 12px 10px 12px 14px; flex: none; } +.head .mark { margin-right: 4px; } +.head .grow { flex: 1; } +.tabs { display: flex; background: var(--sunken); border-radius: 999px; padding: 3px; gap: 2px; } +.tabs button { border-radius: 999px; padding: 5px 14px; font-size: 13px; font-weight: 560; color: var(--muted); } +.tabs button[aria-selected="true"] { background: var(--surface); color: var(--ink); box-shadow: 0 1px 2px rgba(16,24,32,.14); } +.back { display: flex; align-items: center; gap: 6px; font-weight: 620; font-size: 15px; padding: 4px 8px 4px 4px; border-radius: 8px; } +.back svg { width: 16px; height: 16px; } +.body { padding: 4px 20px 18px; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } +.lede h2 { margin: 0; font-size: 19px; line-height: 1.25; font-weight: 660; letter-spacing: -.01em; overflow-wrap: anywhere; } +.lede p { margin: 4px 0 0; color: var(--muted); font-size: 13.5px; } +.lede.withicon { display: flex; gap: 12px; align-items: flex-start; } +.lede.withicon > div { min-width: 0; } +.state-icon { margin-top: 1px; width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; flex: none; background: var(--ok-soft); color: var(--ok); } +.state-icon svg { width: 13px; height: 13px; } +.state-icon.neutral { background: var(--sunken); color: var(--muted); } +.state-icon.warn { background: var(--warn-soft); color: var(--warn); } +.panel { background: var(--sunken); border-radius: 14px; padding: 14px 16px; } +.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.row .l { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; } +.row .l b { font-weight: 580; font-size: 14px; } +.row .l span { color: var(--muted); font-size: 12.5px; } +.stepper { display: flex; align-items: center; gap: 2px; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 2px; flex: none; } +.stepper button { width: 28px; height: 28px; border-radius: 8px; display: grid; place-items: center; color: var(--muted); } +.stepper button:hover { background: var(--sunken); color: var(--ink); } +.stepper button svg { width: 12px; height: 12px; } +.stepper input { width: 40px; border: 0; background: none; text-align: center; font-weight: 620; font-variant-numeric: tabular-nums; color: var(--ink); -moz-appearance: textfield; padding: 0; border-radius: 6px; } +.stepper input::-webkit-inner-spin-button, .stepper input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } +.primary { height: 46px; border-radius: 12px; background: var(--primary); color: var(--on-primary); font-weight: 620; font-size: 15px; display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; } +.primary:hover { background: var(--primary-hover); } +.primary svg { width: 16px; height: 16px; } +.secondary { height: 40px; border-radius: 12px; border: 1px solid var(--line-strong); background: var(--surface); font-weight: 580; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; width: 100%; } +.secondary:hover { background: var(--sunken); } +.secondary svg { width: 14px; height: 14px; } +.quiet { color: var(--muted); font-weight: 560; font-size: 13.5px; padding: 6px 6px; border-radius: 8px; } +.quiet:hover { color: var(--ink); background: var(--sunken); } +.actions { display: flex; flex-direction: column; gap: 8px; } +.split { display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.note { color: var(--muted); font-size: 12.5px; display: flex; gap: 8px; align-items: flex-start; margin: 0; } +.note svg { width: 14px; height: 14px; margin-top: 2px; } +.notice { font-size: 13px; border-radius: 12px; padding: 10px 12px; background: var(--warn-soft); color: var(--warn); display: flex; gap: 10px; align-items: center; justify-content: space-between; } +.notice.info { background: var(--soft); color: var(--soft-ink); } +.notice button { font-weight: 620; text-decoration: underline; text-underline-offset: 2px; flex: none; } + +/* progress */ +.progress-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } +.progress-top b { font-size: 15px; font-weight: 620; } +.progress-top span { color: var(--muted); font-size: 13px; } +.ticks { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; margin: 10px 0; } +.ticks i { height: 8px; border-radius: 4px; background: var(--line); } +.ticks i.done { background: var(--primary); } +.ticks i.now { background: linear-gradient(90deg, var(--primary) 50%, var(--line) 50%); } +.ticks i.skip { background: repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 5px); box-shadow: inset 0 0 0 1px var(--line); } +.count { font-size: 13.5px; } +.count b { font-weight: 620; } +.endline { color: var(--muted); font-size: 12.5px; margin: 0; } + +/* the count, its pages strip and one quiet line */ +.hero { display: flex; flex-direction: column; gap: 10px; } +.tally { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; } +.num { margin: 0; font-size: 15px; color: var(--muted); } +.num b { font-size: 30px; line-height: 1; font-weight: 680; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; margin-right: 2px; } +.hero .ticks { margin: 0; } +.meta { margin: 0; color: var(--muted); font-size: 13px; } +.meta.warn { color: var(--warn); } +.row .label { font-weight: 580; font-size: 14px; } +/* Download Excel, with the other formats unfolding under it */ +.dl-row { display: flex; gap: 2px; } +.dl-main { border-top-right-radius: 4px; border-bottom-right-radius: 4px; } +.dl-more { width: 46px; flex: none; border-top-left-radius: 4px; border-bottom-left-radius: 4px; } +.dl-more svg { width: 14px; height: 14px; } +.dl-more[aria-expanded="true"] svg { transform: rotate(180deg); } +.again { width: 46px; height: 46px; flex: none; margin-left: 6px; border-radius: 12px; border: 1px solid var(--line-strong); display: grid; place-items: center; color: var(--ink); } +.again:hover { background: var(--sunken); } +.again svg { width: 18px; height: 18px; } +.menu { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; } +.menu button { height: 36px; border-radius: 10px; border: 1px solid var(--line-strong); display: flex; align-items: center; justify-content: center; gap: 8px; font-weight: 580; font-size: 13.5px; } +.menu button:hover { background: var(--sunken); } +.menu svg { width: 14px; height: 14px; color: var(--muted); } + +/* toast: floats above the dock so the card never changes shape */ +.toast { position: absolute; right: 0; bottom: calc(100% + 10px); display: flex; align-items: center; gap: 8px; padding: 9px 14px 9px 12px; border-radius: 999px; background: var(--primary); color: var(--on-primary); font-size: 13.5px; font-weight: 560; box-shadow: var(--shadow); white-space: nowrap; animation: toast-in .18s ease-out; } +.toast svg { width: 14px; height: 14px; } +.toast.warn { background: var(--warn-soft); color: var(--warn); white-space: normal; max-width: 320px; border-radius: 14px; } +@keyframes toast-in { from { opacity: 0; transform: translateY(4px); } } +@media (prefers-reduced-motion: reduce) { .toast { animation: none; } } + +/* results */ +.linkrow { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid var(--line); border-radius: 14px; width: 100%; } +.linkrow:hover { background: var(--sunken); } +.linkrow .l { display: flex; flex-direction: column; line-height: 1.3; margin-right: auto; min-width: 0; } +.linkrow .l b { font-weight: 580; } +.linkrow .l span { color: var(--muted); font-size: 12.5px; } +.linkrow svg { width: 16px; height: 16px; color: var(--muted); } +.bar { height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; margin: 10px 0 0; } +.bar i { display: block; height: 100%; background: var(--primary); border-radius: 3px; } + +/* chat */ +.scope { display: inline-flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--soft-ink); background: var(--soft); border-radius: 12px; padding: 5px 12px 5px 10px; align-self: flex-start; max-width: 100%; } +.scope span { overflow-wrap: anywhere; } +.scope svg { width: 14px; height: 14px; } +.scope b { font-weight: inherit; white-space: nowrap; } +.thread { display: flex; flex-direction: column; gap: 10px; min-height: 160px; max-height: 340px; overflow-y: auto; } +.msg { max-width: 86%; padding: 9px 13px; border-radius: 16px; font-size: 14px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; } +.msg.me { align-self: flex-end; background: var(--primary); color: var(--on-primary); border-bottom-right-radius: 6px; } +.msg.ai { align-self: flex-start; background: var(--sunken); border-bottom-left-radius: 6px; } +.msg.ai.error { background: var(--warn-soft); color: var(--warn); } +.msg.ai.wait { color: var(--muted); } +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { border: 1px solid var(--line-strong); border-radius: 999px; padding: 6px 12px; font-size: 13px; background: var(--surface); } +.chip:hover { background: var(--sunken); } +.compose { display: flex; gap: 8px; align-items: center; border: 1px solid var(--line-strong); border-radius: 14px; padding: 5px 5px 5px 14px; background: var(--surface); } +.compose:focus-within { border-color: var(--ring); box-shadow: 0 0 0 1px var(--ring); } +.compose input { flex: 1; border: 0; background: none; color: var(--ink); min-width: 0; padding: 6px 0; } +.compose input:focus-visible { outline: none; } +.compose input::placeholder { color: var(--muted); } +.send { width: 34px; height: 34px; border-radius: 10px; background: var(--primary); color: var(--on-primary); display: grid; place-items: center; flex: none; } +.send svg { width: 16px; height: 16px; } + +/* settings */ +.set { display: flex; flex-direction: column; gap: 16px; } +.set + .set { border-top: 1px solid var(--line); padding-top: 14px; } +.switch { width: 40px; height: 24px; border-radius: 999px; background: var(--line-strong); position: relative; flex: none; } +.switch::after { content: ""; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.25); } +.switch[aria-checked="true"] { background: var(--primary); } +.switch[aria-checked="true"]::after { left: 19px; background: var(--on-primary); } +.status { font-size: 12.5px; display: inline-flex; align-items: center; gap: 6px; color: var(--muted); } +.status i { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } +.status.on { color: var(--ok); } .status.on i { background: var(--ok); } +.compact { height: 34px; border-radius: 10px; padding: 0 12px; font-size: 13px; width: auto; flex: none; } +.optional { color: var(--muted); font-weight: 400; } +.foot { color: var(--muted); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; border-top: 1px solid var(--line); padding-top: 12px; margin: 0; } + +@media (max-width: 480px) { + .dock { right: 12px; bottom: 12px; } + .card { max-height: calc(100vh - 24px); } + .body { padding: 4px 16px 16px; } +} +@media (prefers-reduced-motion: no-preference) { + .card { animation: grow .2s cubic-bezier(.2,.8,.2,1); transform-origin: bottom right; } + @keyframes grow { from { opacity: 0; transform: translateY(6px) scale(.97); } } + .ticks i, .bar i, .ring .val { transition: background-color .3s, width .3s, stroke-dasharray .3s; } +} +`; diff --git a/scripts/content/dock.js b/scripts/content/dock.js new file mode 100644 index 0000000..17628c7 --- /dev/null +++ b/scripts/content/dock.js @@ -0,0 +1,842 @@ +/** + * @fileoverview The ProScan dock: the one on-page surface, bottom-right. + * + * It replaces the old chat bubble. Collapsed, it is a launcher: on a search + * or a seller storefront it offers Scrape in one click, on a seller page it + * points at the storefront, and elsewhere it is a small ProScan button with + * Ask next to it. Opened, it is a card with two tabs: Scrape (start, follow, + * stop and download a run) and Ask (the Gemini chat), plus settings. + * + * The dock never writes run data. It asks the service worker to start a run + * in this tab (START_RUN_HERE; the worker takes the tab from sender.tab), + * follows it with RUN_STATUS and the worker's RUN_PROGRESS and RUN_ENDED, + * and asks for files with DOWNLOAD. Every page of a run is a new document, + * so the dock rebuilds from RUN_STATUS on each load. + * + * Keys and passwords are never typed here: keystrokes in a shadow root + * still reach the page's own listeners. Settings links open ProScan's own + * page instead (OPEN_SETTINGS). + * + * Closed shadow root, constructed stylesheet (dock-styles.js), system + * fonts, inline SVG icons. "Not now" hides the suggestion for the rest of + * the tab session (sessionStorage). + * + * @module Dock + */ + +(function () { + if (window.top !== window) return; + if (typeof PageKind === 'undefined' || PageKind.hidden(location.href)) return; + if (document.getElementById('proscan-dock-host')) return; + + const POLL_MS = 2000; + const SEC_PER_OFFER = 2.5; + const RECENT_MS = 30 * 60 * 1000; + const MAX_HISTORY = 6; + const KEY_NOT_NOW = 'proscan.dock.notNow'; + const KEY_OPEN = 'proscan.dock.open'; + const LIVE = ['starting', 'running', 'stopping']; + const FORMAT_LABEL = { xlsx: 'Excel', csv: 'CSV', json: 'JSON' }; + + const SVG = (body, { fill = false, w = 1.8, box = 16 } = {}) => + ``; + const I = { + mark: SVG('', { box: 24, w: 2 }), + x: SVG(''), + min: SVG(''), + gear: SVG('', { box: 24, w: 2 }), + play: SVG('', { fill: true }), + stop: SVG('', { fill: true }), + down: SVG(''), + file: SVG('', { w: 1.5 }), + chev: SVG(''), + back: SVG(''), + ok: SVG('', { w: 2.2 }), + warn: SVG('', { w: 2.2 }), + pause: SVG('', { w: 2 }), + info: SVG('', { w: 1.5 }), + send: SVG(''), + chat: SVG('', { w: 1.5 }), + scan: SVG('', { w: 1.5 }), + ext: SVG('', { w: 1.6 }), + minus: SVG('', { w: 2 }), + plus: SVG('', { w: 2 }), + again: SVG('', { w: 1.6 }) + }; + + // ── Small helpers ─────────────────────────────────────────── + + function readFlag(key) { + try { return window.sessionStorage.getItem(key) === '1'; } catch (e) { return false; } + } + function writeFlag(key, on) { + try { window.sessionStorage.setItem(key, on ? '1' : '0'); } catch (e) { /* storage blocked */ } + } + + /** False once the extension was updated or removed under this page. */ + function alive() { + try { return !!(chrome.runtime && chrome.runtime.id); } catch (e) { return false; } + } + + /** Sends `message` to the worker; resolves null when nothing answers. */ + function send(message) { + return new Promise((resolve) => { + if (!alive()) return resolve(null); + try { + const out = chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) return resolve(null); + resolve(response || null); + }); + // Test doubles answer with a promise instead of the callback. + if (out && typeof out.then === 'function') out.then((r) => resolve(r || null), () => resolve(null)); + } catch (e) { + resolve(null); + } + }); + } + + const fmt = (n) => Number(n || 0).toLocaleString('en-US'); + const plural = (n, one, many = one + 's') => `${fmt(n)} ${n === 1 ? one : many}`; + const isLive = (run) => !!run && LIVE.includes(run.state); + const isEnded = (run) => !!run && !!run.state && !LIVE.includes(run.state) && run.state !== 'idle'; + + function aboutTime(seconds) { + if (seconds < 60) return 'Under a minute'; + const m = Math.round(seconds / 60); + return `About ${m} ${m === 1 ? 'minute' : 'minutes'}`; + } + + /** + * Builds an element. `attrs` takes class, text, on handlers and + * any attribute; children are nodes or strings (as text). `html` is for + * the icon constants only. + */ + function h(tag, attrs, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === null || v === undefined || v === false) continue; + if (k === 'text') el.textContent = v; + else if (k === 'html') el.innerHTML = v; + else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? '' : String(v)); + } + for (const c of children.flat()) { + if (c === null || c === undefined || c === false) continue; + el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return el; + } + const icon = (name) => h('span', { html: I[name], style: 'display:contents' }); + + // ── This page ─────────────────────────────────────────────── + + function textOf(sel) { + const el = document.querySelector(sel); + const t = el && (el.textContent || '').replace(/\s+/g, ' ').trim(); + return t && t.length <= 60 ? t : null; + } + + /** The store name from the tab title ("Amazon.com: Northfield Goods"), or null. */ + function storeName() { + const t = (document.title || '').replace(/^\s*Amazon\.com\s*[:|-]?\s*/i, '').trim(); + return t && t.length <= 60 && !/^amazon/i.test(t) ? t : null; + } + + /** A seller id from a link on the page, for brand store pages. */ + function sellerFromLinks() { + const links = document.querySelectorAll('a[href*="seller="], a[href*="me="]'); + for (let i = 0; i < links.length && i < 60; i++) { + try { + const u = new URL(links[i].href, location.href); + const id = PageKind.sellerId(u.searchParams.get('seller') || u.searchParams.get('me')); + if (id) return id; + } catch (e) { /* not a URL */ } + } + return null; + } + + function describePage() { + const url = location.href; + const where = PageKind.classify(url); + let parsed = null; + if (where.kind === 'search' || where.kind === 'storefront') { + try { parsed = Parsers.parseSearchPage(document, url); } catch (e) { parsed = null; } + } + const count = parsed ? parsed.products.length : 0; + const total = parsed && Number.isInteger(parsed.total) && parsed.total > 0 ? parsed.total : null; + let sellerId = where.sellerId; + if (where.kind === 'store' && !sellerId) sellerId = sellerFromLinks(); + let name = null; + if (where.kind === 'storefront') name = storeName(); + else if (where.kind === 'search') name = where.keyword ? `"${where.keyword}"` : null; + else if (where.kind === 'seller') name = textOf('#seller-name') || textOf('#sellerName-rd') || textOf('h1'); + else if (where.kind === 'store') name = storeName(); + return { + kind: where.kind, + keyword: where.keyword, + sellerId, + name, + count, + total, + pagesAvail: total && count ? Math.min(Math.ceil(total / count), 400) : null, + startable: !!parsed && ['results', 'last'].includes(parsed.kind), + refusedKind: parsed && !['results', 'last'].includes(parsed.kind) ? parsed.kind : null + }; + } + + const page = describePage(); + const scrapable = page.kind === 'search' || page.kind === 'storefront'; + + // ── State ─────────────────────────────────────────────────── + + const ui = { + open: readFlag(KEY_OPEN), + animate: false, + tab: 'scrape', + status: null, + orphaned: false, + notice: null, + busy: null, + menu: false, + toast: null, + spread: null, + notNow: readFlag(KEY_NOT_NOW), + chat: { status: null, messages: [], draft: '', sending: false } + }; + const history = []; + + let shadow; + let root; + let pollTimer = null; + + const run = () => (ui.status && ui.status.run) || null; + const settings = () => (ui.status && ui.status.settings) || { maxPages: 20, suggest: true }; + + function runLabel(r) { + const src = (r && r.source) || {}; + if (src.type === 'storefront') { + if (src.name) return src.name; + if (page.kind === 'storefront' && page.sellerId === src.sellerId && page.name) return page.name; + return 'this storefront'; + } + return src.keyword ? `"${src.keyword}"` : 'these results'; + } + + function recent(r) { + return !!r && !!r.finishedAt && Date.now() - r.finishedAt < RECENT_MS; + } + + // ── Talking to the worker ────────────────────────────────── + + function unreachable() { + ui.orphaned = !alive(); + ui.notice = { + tone: 'warn', + text: ui.orphaned ? 'ProScan was updated. Reload this page to use it.' : 'Could not reach ProScan. Reload this page and try again.', + action: { label: 'Reload', run: () => location.reload() } + }; + } + + async function refresh() { + clearTimeout(pollTimer); + const st = await send({ type: 'RUN_STATUS' }); + if (st && !st.error) { + ui.status = st; + } else if (!alive()) { + ui.orphaned = true; + } + render(); + // A background tab checks in less often; RUN_PROGRESS still reaches the run's own tab. + if (isLive(run())) pollTimer = setTimeout(refresh, document.hidden ? POLL_MS * 5 : POLL_MS); + } + + async function startHere() { + if (ui.busy) return; + ui.busy = 'start'; + ui.notice = null; + render(); + const r = await send({ type: 'START_RUN_HERE' }); + ui.busy = null; + if (r && r.ok) { + setOpen(true, { focus: 'stop' }); + await refresh(); + return; + } + if (!r) unreachable(); + else ui.notice = { tone: 'warn', text: r.message || 'ProScan could not start on this page.' }; + if (r && r.error === 'busy') await refresh(); + setOpen(true); + } + + async function stopRun() { + if (ui.busy) return; + ui.busy = 'stop'; + render(); + const r = await send({ type: 'STOP_RUN_HERE' }); + ui.busy = null; + if (!r) unreachable(); + await refresh(); + } + + function saveHere({ base64, mime, filename }) { + const bin = atob(base64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const url = URL.createObjectURL(new Blob([bytes], { type: mime })); + const a = h('a', { href: url, download: filename, hidden: true }); + shadow.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } + + async function download(format) { + if (ui.busy) return; + ui.busy = format; + ui.notice = null; + render(); + const r = await send({ type: 'DOWNLOAD', format }); + ui.busy = null; + if (r && r.ok) { + if (r.via === 'page') saveHere(r); + toast(`${FORMAT_LABEL[format]} downloaded`); + } else if (!r) { + unreachable(); + } else { + toast(r.message || 'ProScan could not make the file. Try again.', 'warn'); + } + render(); + } + + let toastTimer = null; + + /** A short message over the dock that goes away on its own, so the card keeps its shape. */ + function toast(text, tone = 'info') { + ui.toast = { text, tone }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { ui.toast = null; render(); }, tone === 'warn' ? 6000 : 2500); + } + + async function compareSellers() { + if (ui.spread || typeof runSpreadAnalysis !== 'function' || (typeof isAnalyzing !== 'undefined' && isAnalyzing)) return; + const data = await send({ type: 'GET_RESULTS' }); + const products = (data && data.results) || []; + if (products.length === 0) return; + ui.spread = { current: 0, total: products.length }; + render(); + const unwatch = watchSpread((ev) => { + if (ev.done) { + ui.spread = null; + unwatch(); + refresh(); + } else { + ui.spread = { current: ev.current, total: ev.total }; + render(); + } + }); + runSpreadAnalysis(products); + } + + function saveSettings(patch) { + ui.status = { ...(ui.status || {}), settings: { ...settings(), ...patch } }; + render(); + send({ type: 'SAVE_SETTINGS', ...patch }).then((r) => { + if (r && r.settings && ui.status) { + ui.status.settings = r.settings; + render(); + } + }); + } + + function openSettingsPage(section) { + send({ type: 'OPEN_SETTINGS', section }).then((r) => { if (!r) { unreachable(); render(); } }); + } + + async function loadChat() { + const st = await send({ type: 'CHAT_STATUS' }); + ui.chat.status = st || { hasKey: false, productCount: 0, unreachable: true }; + render(); + } + + async function ask(question) { + const q = String(question || '').trim(); + if (!q || ui.chat.sending) return; + ui.chat.messages.push({ who: 'me', text: q }); + ui.chat.messages.push({ who: 'ai', text: 'Thinking', wait: true }); + ui.chat.draft = ''; + ui.chat.sending = true; + render(); + const r = await send({ type: 'CHAT_MESSAGE', question: q, history: history.slice(-MAX_HISTORY) }); + const last = ui.chat.messages[ui.chat.messages.length - 1]; + last.wait = false; + if (r && r.answer) { + last.text = r.answer; + history.push({ role: 'user', text: q }, { role: 'model', text: r.answer }); + history.splice(0, Math.max(0, history.length - MAX_HISTORY)); + } else { + last.error = true; + last.text = r && r.error ? r.error : (alive() ? 'Could not reach ProScan. Reload this page and try again.' : 'ProScan was updated. Reload this page to ask.'); + } + ui.chat.sending = false; + render({ focus: 'compose' }); + } + + // ── Opening and closing ───────────────────────────────────── + + let pendingFocus = null; + + function setOpen(open, { tab, focus } = {}) { + ui.open = open; + ui.animate = open; + if (tab) ui.tab = tab; + writeFlag(KEY_OPEN, open); + pendingFocus = focus || (open ? 'first' : 'launcher'); + if (open && ui.tab === 'ask' && !ui.chat.status) loadChat(); + render(); + } + + function notNow() { + ui.notNow = true; + writeFlag(KEY_NOT_NOW, true); + setOpen(false); + } + + // ── Pieces ────────────────────────────────────────────────── + + function mark({ small = false, ring = null, badge = false } = {}) { + const m = h('span', { class: 'mark' + (small ? ' small' : ''), html: I.mark, 'aria-hidden': 'true' }); + if (ring !== null) { + const c = 2 * Math.PI * 23; + const r = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + r.setAttribute('class', 'ring'); + r.setAttribute('viewBox', '0 0 50 50'); + r.innerHTML = ``; + m.appendChild(r); + } + if (badge) m.appendChild(h('span', { class: 'badge', html: I.ok })); + return m; + } + + /** The pages strip: one segment per page up to the page limit, filled as pages are scraped. */ + function ticks(r) { + const live = isLive(r); + const done = r.page || 0; + const max = Math.max(1, r.maxPages || 1, done); + const n = Math.min(max, 20); + const per = max / n; + const row = h('div', { class: 'ticks', title: plural(done, 'page'), 'aria-hidden': 'true' }); + for (let i = 0; i < n; i++) { + let cls = null; + if (done >= (i + 1) * per - 1e-9) cls = 'done'; + else if (live && done >= i * per - 1e-9) cls = 'now'; + row.appendChild(h('i', { class: cls })); + } + return row; + } + + /** The count as the one big number, with an optional control on its right. */ + function tally(n, extra) { + return h('div', { class: 'tally' }, + h('p', { class: 'num' }, h('b', { text: fmt(n) }), ` ${n === 1 ? 'product' : 'products'}`), + extra || null); + } + + function noticeEl() { + const n = ui.notice; + if (!n) return null; + return h('div', { class: 'notice' + (n.tone === 'info' ? ' info' : ''), role: n.tone === 'info' ? 'status' : 'alert' }, + h('span', { text: n.text }), + n.action ? h('button', { text: n.action.label, 'data-k': 'notice', onclick: n.action.run }) : null); + } + + /** Download Excel, with CSV and JSON behind the arrow on its right. */ + function downloads(count, extra = null) { + const pick = (f) => { ui.menu = false; download(f); }; + const item = (f) => h('button', { + role: 'menuitem', 'data-k': f, disabled: !!ui.busy, 'aria-label': `Download ${plural(count, 'product')} as ${FORMAT_LABEL[f]}`, onclick: () => pick(f) + }, icon('file'), FORMAT_LABEL[f]); + return h('div', { class: 'dl' }, + h('div', { class: 'dl-row' }, + h('button', { class: 'primary dl-main', 'data-k': 'xlsx', 'data-first': true, disabled: !!ui.busy, onclick: () => pick('xlsx') }, + icon('down'), ui.busy === 'xlsx' ? 'Downloading' : 'Download Excel'), + h('button', { + class: 'primary dl-more', 'data-k': 'formats', 'aria-label': 'Other formats', 'aria-haspopup': 'menu', 'aria-expanded': String(!!ui.menu), + disabled: !!ui.busy, onclick: () => { ui.menu = !ui.menu; pendingFocus = ui.menu ? 'csv' : 'formats'; render(); } + }, icon('min')), + extra), + ui.menu ? h('div', { class: 'menu', role: 'menu', 'aria-label': 'Other formats' }, item('csv'), item('json')) : null); + } + + function spreadBlock(st) { + if (ui.spread) { + const { current, total } = ui.spread; + return h('div', { class: 'panel', role: 'status', 'aria-live': 'polite' }, + h('div', { class: 'progress-top' }, h('b', { text: `Checking seller prices, ${fmt(current)} of ${fmt(total)}` })), + h('div', { class: 'bar' }, h('i', { style: `width:${total ? Math.round((current / total) * 100) : 0}%` })), + h('p', { class: 'endline', style: 'margin-top:8px', text: 'Keep this tab on this page until it finishes.' }), + h('div', { style: 'margin-top:10px' }, + h('button', { class: 'secondary compact', 'data-k': 'spread-stop', onclick: () => stopSpreadAnalysis() }, icon('stop'), 'Stop checking'))); + } + if (st.spreadCount > 0) { + return h('p', { class: 'note' }, icon('ok'), + h('span', { text: `Seller prices checked for ${plural(st.spreadCount, 'product')}. They are in the Excel and CSV files.` })); + } + const minutes = aboutTime(st.count * SEC_PER_OFFER).toLowerCase(); + return h('button', { class: 'linkrow', 'data-k': 'spread', disabled: !!ui.busy, onclick: compareSellers }, + h('span', { class: 'l' }, h('b', { text: 'Compare seller prices' }), h('span', { text: `Checks other sellers' offers, ${minutes}` })), + icon('chev')); + } + + // ── Launchers ─────────────────────────────────────────────── + + function launcherShell(cls, ...children) { + return h('div', { class: 'launcher ' + cls, role: 'region', 'aria-label': 'ProScan' }, ...children); + } + + function mainButton(label, text, sub, markEl, drop = true) { + return h('button', { class: 'launch-main', 'data-k': 'launcher', 'aria-expanded': 'false', 'aria-label': label, onclick: () => setOpen(true, { tab: 'scrape' }) }, + markEl, + h('span', { class: 'launch-text' + (drop ? ' can-drop' : '') }, h('b', { text }), sub ? h('span', { text: sub }) : null)); + } + + const chatButton = () => h('button', { + class: 'icon-btn', 'data-k': 'chat', 'aria-label': 'Ask about products', title: 'Ask', onclick: () => setOpen(true, { tab: 'ask' }) + }, icon('chat')); + + const hideButton = (label) => h('button', { class: 'icon-btn', 'data-k': 'notnow', 'aria-label': label, title: 'Not now', onclick: notNow }, icon('x')); + + function launcher() { + const st = ui.status; + const r = run(); + if (isLive(r)) { + const cur = Math.min((r.page || 0) + 1, r.maxPages); + const where = r.thisTab ? '' : ' in another tab'; + return launcherShell('running', + mainButton(`Open ProScan, scraping page ${cur} of ${r.maxPages}${where}`, `Page ${fmt(cur)} of ${fmt(r.maxPages)}`, + `${plural(r.itemCount, 'product')} so far${where}`, mark({ ring: (r.page || 0) / r.maxPages }), false), + h('button', { class: 'pill outline', 'data-k': 'stop', disabled: ui.busy === 'stop', 'aria-label': `Stop scraping and keep ${plural(r.itemCount, 'product')}`, onclick: stopRun }, + icon('stop'), 'Stop')); + } + const suggest = settings().suggest !== false && !ui.notNow; + if (isEnded(r) && r.thisTab && recent(r) && !ui.notNow && st && st.count > 0) { + return launcherShell('done', + mainButton('Open ProScan', plural(st.count, 'product'), null, mark({ badge: r.reason === 'complete' })), + h('button', { class: 'pill', 'data-k': 'xlsx-quick', 'aria-label': 'Download Excel', disabled: !!ui.busy, onclick: () => download('xlsx') }, icon('down'), 'Excel'), + chatButton(), + hideButton('Hide for this tab')); + } + if (suggest && scrapable && page.startable) { + const title = page.name || (page.kind === 'storefront' ? 'This storefront' : 'These results'); + const sub = plural(page.kind === 'storefront' && page.total ? page.total : page.count, 'product'); + return launcherShell('suggest', + mainButton(`Open ProScan for ${title}`, title, sub, mark()), + h('button', { class: 'pill', 'data-k': 'scrape-quick', disabled: ui.busy === 'start', onclick: () => startHere() }, icon('play'), 'Scrape'), + chatButton(), + hideButton('Not now, hide for this tab')); + } + if (suggest && (page.kind === 'seller' || page.kind === 'store') && page.sellerId) { + return launcherShell('suggest', + mainButton('Open ProScan', page.name || 'This seller', null, mark()), + h('a', { class: 'pill', 'data-k': 'storefront', href: PageKind.storefrontUrl(page.sellerId) }, 'Open storefront'), + chatButton(), + hideButton('Not now, hide for this tab')); + } + return launcherShell('plain', + h('button', { class: 'launch-main', 'data-k': 'launcher', 'aria-expanded': 'false', 'aria-label': 'Open ProScan', onclick: () => setOpen(true, { tab: 'scrape' }) }, + mark(), h('b', { text: 'ProScan' })), + h('span', { class: 'sep', 'aria-hidden': 'true' }), + chatButton()); + } + + // ── Card ──────────────────────────────────────────────────── + + function header() { + const minimize = h('button', { class: 'icon-btn', 'data-k': 'minimize', 'aria-label': 'Minimize ProScan', 'aria-expanded': 'true', title: 'Minimize', onclick: () => setOpen(false) }, icon('min')); + if (ui.tab === 'settings') { + return h('div', { class: 'head' }, + h('button', { class: 'back', 'data-k': 'back', 'aria-label': 'Back from settings', onclick: () => { ui.tab = 'scrape'; pendingFocus = 'first'; render(); } }, icon('back'), 'Settings'), + h('span', { class: 'grow' }), + minimize); + } + const tab = (id, label) => h('button', { + role: 'tab', id: `ps-tab-${id}`, 'data-k': `tab-${id}`, 'aria-selected': String(ui.tab === id), 'aria-controls': 'ps-panel', + tabindex: ui.tab === id ? '0' : '-1', text: label, + onclick: () => { ui.tab = id; if (id === 'ask' && !ui.chat.status) loadChat(); pendingFocus = `tab-${id}`; render(); } + }); + const tabs = h('div', { + class: 'tabs', role: 'tablist', 'aria-label': 'ProScan', + onkeydown: (e) => { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + e.preventDefault(); + ui.tab = ui.tab === 'scrape' ? 'ask' : 'scrape'; + if (ui.tab === 'ask' && !ui.chat.status) loadChat(); + pendingFocus = `tab-${ui.tab}`; + render(); + } + }, tab('scrape', 'Scrape'), tab('ask', 'Ask')); + return h('div', { class: 'head' }, + mark({ small: true }), + tabs, + h('span', { class: 'grow' }), + h('button', { class: 'icon-btn', 'data-k': 'gear', 'aria-label': 'Settings', title: 'Settings', onclick: () => { ui.tab = 'settings'; pendingFocus = 'first'; render(); } }, icon('gear')), + minimize); + } + + function lede(title, sub, iconEl) { + const text = h('div', {}, h('h2', { text: title }), sub ? h('p', { text: sub }) : null); + return h('div', { class: 'lede' + (iconEl ? ' withicon' : '') }, iconEl, text); + } + + function stateIcon(kind) { + const name = kind === 'ok' ? 'ok' : kind === 'warn' ? 'warn' : 'pause'; + return h('span', { class: 'state-icon' + (kind === 'ok' ? '' : ` ${kind}`), html: I[name], 'aria-hidden': 'true' }); + } + + function stepper(value, onChange, label) { + const set = (v) => onChange(Math.max(1, Math.min(400, Math.round(Number(v) || 1)))); + return h('div', { class: 'stepper' }, + h('button', { 'data-k': 'fewer', 'aria-label': 'Fewer pages', onclick: () => set(value - 1) }, icon('minus')), + h('input', { + type: 'number', min: '1', max: '400', inputmode: 'numeric', value: String(value), 'aria-label': label, 'data-k': 'pages', + onchange: (e) => set(e.target.value) + }), + h('button', { 'data-k': 'more', 'aria-label': 'More pages', onclick: () => set(value + 1) }, icon('plus'))); + } + + function readyView() { + const isStore = page.kind === 'storefront'; + const title = isStore ? (page.name || 'This storefront') : page.keyword ? `"${page.keyword}"` : 'These results'; + const total = isStore && page.total ? page.total : page.count; + const busy = ui.busy === 'start'; + return [ + lede(title, plural(total, 'product')), + noticeEl(), + h('button', { class: 'primary', 'data-k': 'scrape', 'data-first': true, disabled: busy || !!ui.spread, onclick: () => startHere() }, + icon('play'), busy ? 'Starting' : 'Scrape') + ]; + } + + function runningView(r) { + const cur = Math.min((r.page || 0) + 1, r.maxPages); + const stop = h('button', { + class: 'secondary compact', 'data-k': 'stop', 'data-first': true, disabled: ui.busy === 'stop', + 'aria-label': `Stop and keep ${plural(r.itemCount, 'product')}`, onclick: stopRun + }, icon('stop'), ui.busy === 'stop' ? 'Stopping' : 'Stop'); + return [ + h('div', { class: 'hero', role: 'status', 'aria-live': 'polite' }, + tally(r.itemCount, stop), + ticks(r), + h('p', { class: 'meta', text: `Page ${fmt(cur)} of ${fmt(r.maxPages)}${r.thisTab ? '' : ', in another tab'}` })), + noticeEl() + ]; + } + + function doneView(r, st) { + const n = st.count || 0; + let meta = null; + if (r.reason === 'stopped') meta = h('p', { class: 'meta', text: 'Stopped' }); + else if (r.reason !== 'complete') meta = h('p', { class: 'meta warn', text: (Run.MESSAGES && Run.MESSAGES[r.reason]) || 'The run ended early.' }); + const again = scrapable && page.startable && !ui.spread + ? h('button', { + class: 'again', 'data-k': 'again', 'aria-label': 'Scrape again', title: 'Scrape again', disabled: ui.busy === 'start', + onclick: () => { ui.notice = null; ui.menu = false; startHere(); } + }, icon('again')) + : null; + const out = [ + h('div', { class: 'hero' }, tally(n), ticks(r), meta), + noticeEl() + ]; + if (n > 0) out.push(downloads(n, again)); + else if (again) out.push(h('button', { class: 'primary', 'data-k': 'scrape', 'data-first': true, onclick: () => startHere() }, icon('play'), 'Scrape again')); + // Compare seller prices stays hidden until the offer parser works on + // Amazon's current pages (audit F-30); a check already running still shows. + if (ui.spread) out.push(spreadBlock(st)); + return out; + } + + function elsewhereView(st) { + let title = 'Open a search or a storefront'; + let sub = null; + let action = null; + if (scrapable && page.refusedKind) { + title = 'ProScan cannot scrape this page'; + sub = Run.refusal(page.refusedKind); + } else if ((page.kind === 'seller' || page.kind === 'store') && page.sellerId) { + title = page.name || 'This seller'; + action = h('a', { class: 'primary', 'data-k': 'storefront-card', 'data-first': true, href: PageKind.storefrontUrl(page.sellerId) }, 'Open storefront'); + } + const out = [lede(title, sub), action, noticeEl()]; + const r = run(); + if (st && st.count > 0 && r) { + out.push(h('div', { class: 'hero' }, tally(st.count), ticks(r), h('p', { class: 'meta', text: `Last scrape: ${runLabel(r)}` }))); + out.push(downloads(st.count)); + } + return out; + } + + function scrapeView() { + const st = ui.status; + const r = run(); + if (ui.orphaned || !st) { + if (!ui.notice && ui.orphaned) unreachable(); + return [lede('ProScan', st ? '' : 'Loading'), noticeEl()]; + } + if (isLive(r)) return runningView(r); + if (ui.spread || (isEnded(r) && r.thisTab)) return doneView(r, st); + if (scrapable && page.startable) return readyView(); + return elsewhereView(st); + } + + function askView() { + const cs = ui.chat.status; + if (!cs) return [h('p', { class: 'note', text: 'Loading' })]; + const out = []; + const count = cs.productCount || 0; + if (count > 0) { + out.push(h('div', { class: 'scope', title: cs.source || '' }, icon('scan'), h('span', { text: plural(count, 'product') }))); + } + if (cs.unreachable) { + out.push(h('div', { class: 'notice', role: 'alert' }, h('span', { text: alive() ? 'Could not reach ProScan. Reload this page and try again.' : 'ProScan was updated. Reload this page to ask.' }))); + return out; + } + if (!cs.hasKey) { + out.push(lede('Add a free Gemini key to ask')); + out.push(h('button', { class: 'primary', 'data-k': 'add-key', 'data-first': true, onclick: () => openSettingsPage('key') }, 'Add key', icon('ext'))); + return out; + } + if (count === 0) { + out.push(lede('Scrape something first')); + return out; + } + const thread = h('div', { class: 'thread', 'aria-live': 'polite' }); + for (const m of ui.chat.messages) { + thread.appendChild(h('div', { class: `msg ${m.who}${m.error ? ' error' : ''}${m.wait ? ' wait' : ''}`, text: m.wait ? 'Thinking...' : m.text })); + } + out.push(thread); + if (ui.chat.messages.length === 0) { + out.push(h('div', { class: 'chips' }, + ['Best under $30', 'Top rated', 'Sponsored ones'].map((q, i) => + h('button', { class: 'chip', 'data-k': `chip-${i}`, text: q, onclick: () => ask(q) })))); + } + const input = h('input', { + type: 'text', 'data-k': 'compose', 'data-first': true, placeholder: 'Ask anything', 'aria-label': 'Ask about these products', + value: ui.chat.draft, autocomplete: 'off', disabled: ui.chat.sending, + oninput: (e) => { ui.chat.draft = e.target.value; }, + onkeydown: (e) => { if (e.key === 'Enter') { e.preventDefault(); ask(ui.chat.draft); } } + }); + out.push(h('div', { class: 'compose' }, input, + h('button', { class: 'send', 'data-k': 'send', 'aria-label': 'Send', disabled: ui.chat.sending, onclick: () => ask(ui.chat.draft) }, icon('send')))); + return out; + } + + function settingsView() { + const st = ui.status || {}; + const s = settings(); + const version = (() => { try { return chrome.runtime.getManifest().version; } catch (e) { return ''; } })(); + const row = (label, control, id) => h('div', { class: 'row' }, h('span', { class: 'label', id: id || null, text: label }), control); + const sections = [ + h('div', { class: 'set' }, + row('Pages per run', stepper(s.maxPages, (v) => saveSettings({ maxPages: v }), 'Pages per run')), + row('Suggest Scrape', h('button', { + class: 'switch', role: 'switch', 'data-k': 'suggest', 'data-first': true, 'aria-checked': String(s.suggest !== false), 'aria-labelledby': 'ps-suggest-label', + onclick: () => saveSettings({ suggest: s.suggest === false }) + }), 'ps-suggest-label'), + row('Gemini key', h('button', { class: 'secondary compact', 'data-k': 'key', onclick: () => openSettingsPage('key') }, st.hasKey ? 'Change' : 'Add', icon('ext'))), + st.cloudSync + ? row('Dashboard sync', h('button', { class: 'secondary compact', 'data-k': 'sync', onclick: () => openSettingsPage('sync') }, st.signedIn ? 'Manage' : 'Sign in', icon('ext'))) + : null) + ]; + sections.push(noticeEl()); + if (version) sections.push(h('p', { class: 'foot' }, h('span', { text: `ProScan ${version}` }))); + return sections; + } + + function card() { + const view = ui.tab === 'settings' ? settingsView() : ui.tab === 'ask' ? askView() : scrapeView(); + const body = h('div', { + class: 'body', id: 'ps-panel', + role: ui.tab === 'settings' ? null : 'tabpanel', + 'aria-labelledby': ui.tab === 'settings' ? null : `ps-tab-${ui.tab}` + }, view); + return h('section', { + class: 'card', role: 'dialog', 'aria-label': 'ProScan', + style: ui.animate ? null : 'animation:none' + }, header(), body); + } + + // ── Rendering ─────────────────────────────────────────────── + + function render({ focus } = {}) { + if (!root) return; + const active = shadow.activeElement; + const keep = focus || pendingFocus || (active && active.getAttribute && active.getAttribute('data-k')); + let caret = null; + if (active && active.tagName === 'INPUT' && active.type === 'text') caret = [active.selectionStart, active.selectionEnd]; + const thread = root.querySelector('.thread'); + const stuck = thread ? thread.scrollHeight - thread.scrollTop - thread.clientHeight < 8 : true; + + const tb = ui.toast + ? h('div', { class: 'toast' + (ui.toast.tone === 'warn' ? ' warn' : ''), role: ui.toast.tone === 'warn' ? 'alert' : 'status' }, + icon(ui.toast.tone === 'warn' ? 'warn' : 'ok'), h('span', { text: ui.toast.text })) + : null; + root.replaceChildren(...[tb, ui.open ? card() : launcher()].filter(Boolean)); + ui.animate = false; + pendingFocus = null; + + const t = root.querySelector('.thread'); + if (t && stuck) t.scrollTop = t.scrollHeight; + if (!keep) return; + let el = null; + if (keep === 'first') el = root.querySelector('[data-first]:not([disabled])') || root.querySelector('button:not([disabled])'); + else if (keep === 'launcher') el = root.querySelector('[data-k="launcher"]'); + else el = root.querySelector(`[data-k="${keep}"]:not([disabled])`); + if (!el && active && root.contains(active)) el = root.querySelector('button:not([disabled])'); + if (el) { + el.focus({ preventScroll: true }); + if (caret && el.tagName === 'INPUT' && typeof el.setSelectionRange === 'function') { + try { el.setSelectionRange(caret[0], caret[1]); } catch (e) { /* not a text field */ } + } + } + } + + function adoptStyles() { + try { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(DOCK_CSS); + shadow.adoptedStyleSheets = [sheet]; + return; + } catch (e) { /* no constructed sheets here */ } + shadow.appendChild(h('style', { text: DOCK_CSS })); + } + + function init() { + const host = document.createElement('div'); + host.id = 'proscan-dock-host'; + host.setAttribute('style', 'all:initial;position:fixed;z-index:2147483647;right:0;bottom:0;width:0;height:0;'); + (document.body || document.documentElement).appendChild(host); + shadow = host.attachShadow({ mode: 'closed' }); + adoptStyles(); + root = h('div', { class: 'dock' }); + // Keys typed in the dock stay out of Amazon's shortcuts. + ['keydown', 'keyup', 'keypress'].forEach((type) => root.addEventListener(type, (e) => { + if (type === 'keydown' && e.key === 'Escape' && ui.open) { + e.preventDefault(); + if (ui.menu) { ui.menu = false; pendingFocus = 'formats'; render(); } else setOpen(false); + } + e.stopPropagation(); + })); + shadow.appendChild(root); + render(); + refresh(); + if (ui.open && ui.tab === 'ask') loadChat(); + + chrome.runtime.onMessage.addListener((m) => { + if (!alive() || !m) return false; + if (m.type === 'RUN_PROGRESS' || m.type === 'RUN_ENDED') refresh(); + return false; + }); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init, { once: true }); + else init(); +})(); diff --git a/scripts/content/offer-fetcher.js b/scripts/content/offer-fetcher.js index c2be592..4665873 100644 --- a/scripts/content/offer-fetcher.js +++ b/scripts/content/offer-fetcher.js @@ -23,6 +23,29 @@ /** @type {boolean} Whether spread analysis is currently running */ let isAnalyzing = false; +/** + * Callbacks in this page that follow the analysis (the dock). A content + * script's runtime.sendMessage never reaches the same page, so the dock + * watches here. Each gets {current, total} per product and {done, total}. + */ +const spreadWatchers = new Set(); + +function watchSpread(fn) { + spreadWatchers.add(fn); + return () => spreadWatchers.delete(fn); +} + +function notifySpread(event) { + spreadWatchers.forEach(fn => { + try { fn(event); } catch (e) { /* a watcher's error is its own */ } + }); +} + +/** Stops the analysis after the product it is on. */ +function stopSpreadAnalysis() { + isAnalyzing = false; +} + /** False once the extension was updated or removed under this page. */ function offersAlive() { try { @@ -167,6 +190,8 @@ async function runSpreadAnalysis(products) { // The worker stores it await tell({ type: Msg.T.SPREAD_RESULT, asin, data: spreadResults[asin] }); + notifySpread({ current: i + 1, total, asin, hasData: offerData !== null }); + // Send progress update to popup tell({ type: Msg.T.SPREAD_PROGRESS, @@ -184,6 +209,7 @@ async function runSpreadAnalysis(products) { // Analysis complete isAnalyzing = false; + notifySpread({ done: true, total, analyzed: Object.keys(spreadResults).length }); tell({ type: Msg.T.SPREAD_ANALYSIS_COMPLETE, diff --git a/scripts/content/scraper.js b/scripts/content/scraper.js index a285920..32dfa86 100644 --- a/scripts/content/scraper.js +++ b/scripts/content/scraper.js @@ -131,7 +131,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.type === Msg.T.PING) { const page = parsePage(); - sendResponse({ ok: true, kind: page.kind, count: page.products.length, url: window.location.href }); + sendResponse({ ok: true, kind: page.kind, count: page.products.length, url: window.location.href, title: String(document.title || '').slice(0, 200) }); return false; } diff --git a/scripts/lib/chat.js b/scripts/lib/chat.js index 88919db..c97d05b 100644 --- a/scripts/lib/chat.js +++ b/scripts/lib/chat.js @@ -76,6 +76,7 @@ const Chat = (() => { function sourceLabel(meta) { if (!meta) return ''; if (meta.keyword) return 'search "' + cleanText(meta.keyword, 80) + '"'; + if (meta.name) return cleanText(meta.name, 60); if (meta.sellerId) return 'storefront ' + cleanText(meta.sellerId, 40); return ''; } diff --git a/scripts/lib/messages.js b/scripts/lib/messages.js index eade46b..142a835 100644 --- a/scripts/lib/messages.js +++ b/scripts/lib/messages.js @@ -25,6 +25,15 @@ const Msg = (() => { PAGE_RESULT: { to: 'worker', from: 'tab' }, HEARTBEAT: { to: 'worker', from: 'tab' }, + // The dock on the page -> worker. The run is always bound to the + // sender's own tab (sender.tab.id), never to a tab id in the message. + START_RUN_HERE: { to: 'worker', from: 'tab' }, + STOP_RUN_HERE: { to: 'worker', from: 'tab' }, + RUN_STATUS: { to: 'worker', from: 'tab' }, + SAVE_SETTINGS: { to: 'worker', from: 'tab' }, + DOWNLOAD: { to: 'worker', from: 'tab' }, + OPEN_SETTINGS: { to: 'worker', from: 'tab' }, + // Spread analysis in the tab GET_RESULTS: { to: 'worker', from: 'any' }, SPREAD_RESULT: { to: 'worker', from: 'tab' }, @@ -44,6 +53,7 @@ const Msg = (() => { PING: { to: 'tab' }, PARSE_PAGE: { to: 'tab' }, RUN_ENDED: { to: 'tab' }, + RUN_PROGRESS: { to: 'tab' }, START_SPREAD_ANALYSIS: { to: 'tab' }, STOP_SPREAD_ANALYSIS: { to: 'tab' }, diff --git a/scripts/lib/page-kind.js b/scripts/lib/page-kind.js new file mode 100644 index 0000000..5f3e8a8 --- /dev/null +++ b/scripts/lib/page-kind.js @@ -0,0 +1,140 @@ +/** + * @fileoverview What kind of Amazon page a URL is, for the on-page dock. + * + * The dock offers Scrape only where a run can start (search results and + * seller storefronts) and points seller pages at their storefront. It never + * offers anything on product, cart, checkout, sign-in or account pages. + * + * Kinds: + * - search: /s with a keyword or a category + * - storefront: /s?me=SELLER, the seller's product list + * - seller: /sp?seller=SELLER, the seller profile; its storefront is /s?me=SELLER + * - store: /stores/..., a brand store; a run cannot start there + * - never: product, cart, checkout, sign-in, account and order pages + * - other: any other Amazon page + * + * Loaded as a plain global (`PageKind`) in content scripts and the popup, + * and as a CommonJS module in Jest. + * + * @module PageKind + */ + +const PageKind = (() => { + // Paths where the dock must stay quiet, whatever the query says. + const NEVER = [ + /\/dp\//i, + /^\/gp\/product\//i, + /^\/gp\/aw\/d\//i, + /^\/gp\/offer-listing\//i, + /^\/gp\/aod\//i, + /^\/gp\/cart\//i, + /^\/cart(\/|$)/i, + /^\/gp\/buy\//i, + /^\/checkout(\/|$)/i, + /^\/gp\/checkout/i, + /^\/ap\//i, + /^\/ax\//i, + /^\/gp\/css\//i, + /^\/gp\/your-account/i, + /^\/your-account/i, + /^\/your-orders/i, + /^\/gp\/yourstore/i, + /^\/hz\//i, + /^\/a\/addresses/i, + /^\/cpe\/yourpayments/i, + /^\/mn\/dcw\/myx/i + ]; + + // Of those, where the dock stays off the page entirely: money, sign-in + // and account pages. Product pages keep the plain launcher for Ask. + const HIDDEN = [ + /^\/gp\/cart\//i, + /^\/cart(\/|$)/i, + /^\/gp\/buy\//i, + /^\/checkout(\/|$)/i, + /^\/gp\/checkout/i, + /^\/ap\//i, + /^\/ax\//i, + /^\/gp\/css\//i, + /^\/gp\/your-account/i, + /^\/your-account/i, + /^\/your-orders/i, + /^\/a\/addresses/i, + /^\/cpe\/yourpayments/i, + /^\/mn\/dcw\/myx/i + ]; + + const SELLER_ID = /^[A-Z0-9]{8,20}$/i; + + function parse(url) { + try { + const u = new URL(url); + return /(^|\.)amazon\.com$/i.test(u.hostname) ? u : null; + } catch (e) { + return null; + } + } + + /** A seller id from a query value, or null when it does not look like one. */ + function sellerId(value) { + const v = String(value || '').trim(); + return SELLER_ID.test(v) ? v.toUpperCase() : null; + } + + /** The storefront URL for a seller id. */ + function storefrontUrl(id) { + return `https://www.amazon.com/s?me=${encodeURIComponent(id)}&marketplaceID=ATVPDKIKX0DER`; + } + + /** + * @param {string} url + * @returns {{kind: string, sellerId: ?string, keyword: ?string}} + */ + function classify(url) { + const u = parse(url); + const out = { kind: 'other', sellerId: null, keyword: null }; + if (!u) return { ...out, kind: 'never' }; + const path = u.pathname; + if (NEVER.some((re) => re.test(path))) return { ...out, kind: 'never' }; + + if (path === '/s' || path.startsWith('/s/')) { + const me = sellerId(u.searchParams.get('me')); + if (me) return { ...out, kind: 'storefront', sellerId: me }; + const k = u.searchParams.get('k') || u.searchParams.get('field-keywords'); + if (k || u.searchParams.get('rh') || u.searchParams.get('i') || u.searchParams.get('node')) { + return { ...out, kind: 'search', keyword: k ? k.trim() : null }; + } + return out; + } + if (path === '/sp' || path.startsWith('/sp/')) { + const id = sellerId(u.searchParams.get('seller')); + return id ? { ...out, kind: 'seller', sellerId: id } : out; + } + if (path.startsWith('/stores/')) { + return { ...out, kind: 'store', sellerId: sellerId(u.searchParams.get('seller') || u.searchParams.get('me')) }; + } + return out; + } + + /** True where the dock may suggest something: a scrape, or the way to one. */ + function suggests(url) { + return ['search', 'storefront', 'seller', 'store'].includes(classify(url).kind); + } + + /** True where a run can start from the page itself. */ + function scrapable(url) { + return ['search', 'storefront'].includes(classify(url).kind); + } + + /** True where the dock should not appear at all. */ + function hidden(url) { + const u = parse(url); + return !u || HIDDEN.some((re) => re.test(u.pathname)); + } + + return { classify, suggests, scrapable, hidden, storefrontUrl, sellerId }; +})(); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = PageKind; +} diff --git a/scripts/modules/exporter.js b/scripts/modules/exporter.js index 7573d4b..a2b922a 100644 --- a/scripts/modules/exporter.js +++ b/scripts/modules/exporter.js @@ -432,6 +432,27 @@ const Exporter = { }; }, + /** + * The file for `format` (xlsx, csv or json), with the spread columns + * when the run has spread data. The popup and the service worker both + * make files here. + * + * @param {string} format + * @param {Object[]} results + * @param {Object} [spread=null] - ASIN to offers, from spread analysis + * @returns {{blob: Blob, filename: string}} + */ + build(format, results, spread = null) { + const withSpread = spread && Object.keys(spread).length > 0 ? spread : null; + if (format === 'xlsx') { + const report = typeof Analyzer !== 'undefined' ? Analyzer.generateFullReport(results) : null; + return this.exportToExcel(results, report); + } + if (format === 'csv') return this.exportToCSV(results, withSpread); + if (format === 'json') return this.exportToJSON(results, true); + throw new Error(`unknown export format: ${format}`); + }, + /** * Trigger a file download using the Chrome Downloads API. * Opens a save-as dialog and cleans up the object URL after download. diff --git a/styles/chatbot.css b/styles/chatbot.css deleted file mode 100644 index 5059e78..0000000 --- a/styles/chatbot.css +++ /dev/null @@ -1,293 +0,0 @@ -/* - * ProScan Chatbot Widget Styles - * - * Injected into a closed Shadow DOM to prevent conflicts - * with Amazon's page styles. Uses CSS custom properties - * for consistent theming across the widget. - */ - -:host { - /* Core palette */ - --cb-bg-primary: #2A343E; - --cb-bg-surface: rgba(253, 253, 253, 0.08); - --cb-bg-border: rgba(253, 253, 253, 0.08); - --cb-text-primary: #FDFDFD; - --cb-text-muted: rgba(253, 253, 253, 0.5); - --cb-text-placeholder: rgba(253, 253, 253, 0.3); - - /* Accent colors */ - --cb-accent-green: #4CAF50; - --cb-accent-green-dark: #388E3C; - --cb-accent-green-muted: rgba(76, 175, 80, 0.1); - - /* Message bubble colors */ - --cb-bubble-ai-bg: rgba(76, 175, 80, 0.12); - --cb-bubble-ai-text: #C8E6C9; - --cb-bubble-user-bg: rgba(33, 150, 243, 0.18); - --cb-bubble-user-text: #B3D9FF; - --cb-bubble-error-bg: rgba(244, 67, 54, 0.15); - --cb-bubble-error-text: #EF9A9A; - - /* Badge */ - --cb-badge-bg: rgba(76, 175, 80, 0.2); - --cb-badge-text: #81C784; - - /* Layout */ - --cb-panel-width: 360px; - --cb-panel-height: 460px; - --cb-toggle-size: 52px; - --cb-toggle-icon-size: 34px; - - /* Typography */ - --cb-font-family: 'Segoe UI', Arial, sans-serif; - --cb-font-size-sm: 11px; - --cb-font-size-base: 13px; - --cb-font-size-md: 14px; - - /* Spacing */ - --cb-spacing-sm: 8px; - --cb-spacing-md: 12px; - --cb-spacing-lg: 14px; - --cb-spacing-xl: 16px; - - /* Borders */ - --cb-radius-sm: 4px; - --cb-radius-md: 8px; - --cb-radius-lg: 12px; - --cb-radius-bubble: 14px; - - /* Transitions */ - --cb-transition: 0.2s ease; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -/* Toggle button -- always visible, fixed bottom-right */ -.proscan-toggle { - position: fixed; - bottom: 24px; - right: 24px; - width: var(--cb-toggle-size); - height: var(--cb-toggle-size); - border-radius: 50%; - background: var(--cb-bg-primary); - border: none; - cursor: pointer; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); - z-index: 2147483647; - display: flex; - align-items: center; - justify-content: center; - transition: transform var(--cb-transition), box-shadow var(--cb-transition); - padding: 0; - overflow: hidden; -} - -.proscan-toggle:hover { - transform: scale(1.08); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); -} - -.proscan-toggle img { - width: var(--cb-toggle-icon-size); - height: var(--cb-toggle-icon-size); - border-radius: 50%; -} - -/* Chat panel -- hidden by default, shown with .open class */ -.proscan-panel { - position: fixed; - bottom: 88px; - right: 24px; - width: var(--cb-panel-width); - height: var(--cb-panel-height); - background: var(--cb-bg-primary); - border-radius: var(--cb-radius-lg); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - z-index: 2147483646; - display: none; - flex-direction: column; - overflow: hidden; - font-family: var(--cb-font-family); - color: var(--cb-text-primary); -} - -.proscan-panel.open { - display: flex; -} - -/* Header */ -.proscan-header { - padding: var(--cb-spacing-lg) var(--cb-spacing-xl); - background: var(--cb-accent-green-muted); - border-bottom: 1px solid var(--cb-bg-border); - display: flex; - align-items: center; - justify-content: space-between; -} - -.proscan-header-title { - font-size: var(--cb-font-size-md); - font-weight: 600; - display: flex; - align-items: center; - gap: var(--cb-spacing-sm); - color: var(--cb-text-primary); -} - -.proscan-header-title img { - width: 20px; - height: 20px; -} - -.proscan-close { - background: none; - border: none; - color: var(--cb-text-muted); - font-size: 18px; - cursor: pointer; - padding: 4px; - line-height: 1; -} - -.proscan-close:hover { - color: var(--cb-text-primary); -} - -/* Messages area */ -.proscan-messages { - flex: 1; - overflow-y: auto; - padding: var(--cb-spacing-md) var(--cb-spacing-lg); - display: flex; - flex-direction: column; - gap: var(--cb-spacing-sm); -} - -/* Chat bubbles */ -.proscan-bubble { - padding: 10px var(--cb-spacing-lg); - border-radius: var(--cb-radius-bubble); - font-size: var(--cb-font-size-base); - line-height: 1.5; - max-width: 85%; - word-wrap: break-word; -} - -.proscan-bubble.ai { - background: var(--cb-bubble-ai-bg); - color: var(--cb-bubble-ai-text); - align-self: flex-start; - border-bottom-left-radius: var(--cb-radius-sm); -} - -.proscan-bubble.user { - background: var(--cb-bubble-user-bg); - color: var(--cb-bubble-user-text); - align-self: flex-end; - border-bottom-right-radius: var(--cb-radius-sm); -} - -.proscan-bubble.loading { - opacity: 0.6; - font-style: italic; -} - -.proscan-bubble.error { - background: var(--cb-bubble-error-bg); - color: var(--cb-bubble-error-text); -} - -/* Input area */ -.proscan-input-row { - padding: var(--cb-spacing-md) var(--cb-spacing-lg); - border-top: 1px solid var(--cb-bg-border); - display: flex; - gap: var(--cb-spacing-sm); -} - -.proscan-input { - flex: 1; - background: var(--cb-bg-surface); - border: 1px solid rgba(253, 253, 253, 0.12); - border-radius: var(--cb-radius-md); - padding: 10px var(--cb-spacing-md); - color: var(--cb-text-primary); - font-size: var(--cb-font-size-base); - outline: none; - font-family: inherit; - transition: border-color var(--cb-transition); -} - -.proscan-input:focus { - border-color: var(--cb-accent-green); -} - -.proscan-input::placeholder { - color: var(--cb-text-placeholder); -} - -.proscan-send { - background: linear-gradient(135deg, var(--cb-accent-green) 0%, var(--cb-accent-green-dark) 100%); - border: none; - border-radius: var(--cb-radius-md); - color: white; - padding: 10px var(--cb-spacing-lg); - cursor: pointer; - font-size: var(--cb-font-size-md); - transition: opacity var(--cb-transition); - display: flex; - align-items: center; -} - -.proscan-send:hover { - opacity: 0.9; -} - -.proscan-send:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.proscan-send svg { - width: 16px; - height: 16px; - fill: white; -} - -/* No API key notice */ -.proscan-notice { - padding: 10px var(--cb-spacing-lg); - font-size: 12px; - color: #FFD54F; - text-align: center; - border-top: 1px solid var(--cb-bg-border); -} - -/* Product count badge */ -.proscan-badge { - background: var(--cb-badge-bg); - color: var(--cb-badge-text); - font-size: var(--cb-font-size-sm); - padding: 2px var(--cb-spacing-sm); - border-radius: 10px; - font-weight: 500; -} - -/* Custom scrollbar */ -.proscan-messages::-webkit-scrollbar { - width: 5px; -} - -.proscan-messages::-webkit-scrollbar-track { - background: transparent; -} - -.proscan-messages::-webkit-scrollbar-thumb { - background: rgba(253, 253, 253, 0.15); - border-radius: 3px; -} diff --git a/tests/e2e/dock.spec.mjs b/tests/e2e/dock.spec.mjs new file mode 100644 index 0000000..05d74f9 --- /dev/null +++ b/tests/e2e/dock.spec.mjs @@ -0,0 +1,154 @@ +// The on-page dock in real Chromium, against saved pages. The dock sits in +// a closed shadow root; tests/e2e/lib/dock.mjs reaches it through CDP. +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { test as base, expect } from '@playwright/test'; +import { launch, extPage, getState, waitForState, endReason, sleep, ended } from './lib/extension.mjs'; +import { serveAmazon, simplePlan, corpusPage, asinFor } from './lib/amazon.mjs'; +import { dock } from './lib/dock.mjs'; + +const require = createRequire(import.meta.url); + +const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + ext: async ({}, use) => { + const ext = await launch(); + await use(ext); + await ext.close(); + }, +}); + +const searchUrl = (k, p) => `https://www.amazon.com/s?k=${encodeURIComponent(k).replace(/%20/g, '+')}${p ? `&page=${p}` : ''}`; +const pagesOf = (served, k) => served.filter((p) => p.keyword === k).map((p) => p.page); + +async function open(ext, url) { + const tab = await ext.context.newPage(); + await tab.goto(url); + const d = await dock(tab); + await d.waitFor('.launcher'); + return { tab, d }; +} + +/** The id Chrome gave `tab`, asked from an extension page. */ +async function tabIdOf(store, tab) { + return store.evaluate((url) => new Promise((r) => chrome.tabs.query({ url }, (t) => r(t[0] && t[0].id))), tab.url()); +} + +test('the dock suggests Scrape on a saved search page, with its product count', async ({ ext }) => { + const yoga = corpusPage('2026-09/search-yoga-mat.html'); + await serveAmazon(ext.context, () => ({ body: yoga })); + const { d } = await open(ext, searchUrl('yoga mat')); + expect(await d.has('.launcher.suggest')).toBe(true); + const Parsers = require('../../scripts/lib/parsers.js'); + const { parseDoc } = require('../setup/corpus.js'); + const n = Parsers.parseSearchPage(parseDoc(yoga, searchUrl('yoga mat')), searchUrl('yoga mat')).products.length; + expect(await d.text('.launcher')).toContain(`"yoga mat"`); + expect(await d.text('.launcher')).toContain(`${n} products`); + expect(await d.attr('[data-k="notnow"]', 'aria-label')).toBe('Not now, hide for this tab'); +}); + +test('Scrape in the dock runs in that tab, page by page, and completes', async ({ ext }) => { + const served = await serveAmazon(ext.context, simplePlan(3)); + const store = await extPage(ext); + const other = await ext.context.newPage(); + await other.goto(searchUrl('elsewhere')); + const { tab, d } = await open(ext, searchUrl('garden hose')); + await d.click('[data-k="scrape-quick"]'); + const s = await waitForState(store, (x) => x.scrapeRunPages?.length >= 3 && !x.isScrapingActive, { timeout: 40000 }); + + expect(s.run.tabId).toBe(await tabIdOf(store, tab)); + expect(pagesOf(served, 'garden hose')).toEqual([1, 2, 3]); + expect(pagesOf(served, 'elsewhere')).toEqual([1]); + expect(s.results.map((r) => r.asin)).toEqual([1, 2, 3].flatMap((p) => [0, 1, 2, 3].map((i) => asinFor('garden hose', p, i)))); + expect(endReason(s)).toBe('complete'); + + // The last page's dock rebuilt itself from the worker and shows the result. + const after = await dock(tab); + await after.waitForText(/12 products/, { timeout: 10000 }); + await after.waitFor('[data-k="xlsx"]'); + expect(await after.count('.ticks i.done')).toBe(3); +}); + +test('Stop in the dock halts the run before the next page', async ({ ext }) => { + const served = await serveAmazon(ext.context, simplePlan(4)); + const store = await extPage(ext); + const { tab, d } = await open(ext, searchUrl('stop me')); + await d.click('[data-k="launcher"]'); + await d.waitFor('[data-k="scrape"]'); + await d.click('[data-k="scrape"]'); + await waitForState(store, (x) => x.scrapeRunPages?.length >= 1, { timeout: 15000, interval: 100 }); + const live = await dock(tab); + await live.waitFor('[data-k="stop"]'); + expect(await live.attr('[data-k="stop"]', 'aria-label')).toBe('Stop and keep 4 products'); + await live.click('[data-k="stop"]'); + await sleep(4500); + const s = await getState(store); + + expect(s.isScrapingActive).toBe(false); + expect(endReason(s)).toBe('stopped'); + expect(pagesOf(served, 'stop me')).toEqual([1]); + await live.waitForText(/4 products.*Stopped/); +}); + +test('a product page gets the plain launcher and no Scrape; the cart gets no dock', async ({ ext }) => { + await serveAmazon(ext.context, simplePlan(1)); + const { tab, d } = await open(ext, 'https://www.amazon.com/dp/B09B8V1LZ3'); + expect(await d.has('.launcher.plain')).toBe(true); + expect(await d.has('[data-k="scrape-quick"]')).toBe(false); + await d.click('[data-k="launcher"]'); + await d.waitForText(/Open a search or a storefront/); + expect(await d.has('[data-k="scrape"]')).toBe(false); + + await ext.context.route('https://www.amazon.com/gp/cart/**', (r) => r.fulfill({ status: 200, contentType: 'text/html', body: '

Cart

' })); + await tab.goto('https://www.amazon.com/gp/cart/view.html'); + await sleep(1200); + expect(await (await dock(tab)).present()).toBe(false); +}); + +test('Not now hides the suggestion for the rest of the tab, not in a new tab', async ({ ext }) => { + await serveAmazon(ext.context, simplePlan(2)); + const { tab, d } = await open(ext, searchUrl('dismiss')); + await d.click('[data-k="notnow"]'); + await d.waitFor('.launcher.plain'); + await tab.goto(searchUrl('dismiss', 2)); + const again = await dock(tab); + await again.waitFor('.launcher'); + expect(await again.has('.launcher.plain')).toBe(true); + + const fresh = await open(ext, searchUrl('dismiss')); + expect(await fresh.d.has('.launcher.suggest')).toBe(true); +}); + +test('Download Excel in the dock saves the run through chrome.downloads', async ({ ext }) => { + await serveAmazon(ext.context, simplePlan(2)); + const store = await extPage(ext); + const { tab, d } = await open(ext, searchUrl('export me')); + await d.click('[data-k="scrape-quick"]'); + await waitForState(store, (x) => ended(x) && x.scrapeRunPages?.length >= 2, { timeout: 40000 }); + const done = await dock(tab); + await done.waitFor('[data-k="xlsx"]', { timeout: 10000 }); + await done.click('[data-k="xlsx"]'); + await done.waitForText(/Excel downloaded/, { timeout: 15000 }); + await done.click('[data-k="formats"]'); + await done.click('[data-k="csv"]'); + await done.waitForText(/CSV downloaded/, { timeout: 15000 }); + + const items = await store.evaluate(async () => { + const shape = (f) => ({ url: f.url.slice(0, 60), mime: f.mime, state: f.state, error: f.error || null, bytes: f.totalBytes, file: f.filename }); + for (let i = 0; i < 40; i++) { + const found = await chrome.downloads.search({}); + if (found.length >= 2 && found.every((f) => f.state !== 'in_progress')) return found.map(shape); + await new Promise((r) => setTimeout(r, 250)); + } + return (await chrome.downloads.search({})).map(shape); + }); + // Playwright keeps downloads under its own names, so check type and bytes. + expect(items.map((i) => [i.mime, i.state, i.error]).sort()).toEqual([ + ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'complete', null], + ['text/csv', 'complete', null], + ]); + const csv = fs.readFileSync(items.find((i) => i.mime === 'text/csv').file, 'utf8'); + expect(csv).toContain(asinFor('export me', 2, 3)); + const xlsx = fs.readFileSync(items.find((i) => i.mime !== 'text/csv').file); + expect(xlsx.subarray(0, 2).toString()).toBe('PK'); +}); diff --git a/tests/e2e/lib/dock.mjs b/tests/e2e/lib/dock.mjs new file mode 100644 index 0000000..c932923 --- /dev/null +++ b/tests/e2e/lib/dock.mjs @@ -0,0 +1,112 @@ +// Reaches the dock inside its closed shadow root through CDP, which can +// pierce closed roots. Page scripts cannot, and neither can Playwright's +// locators, so the tests read and click it here. Clicks are real mouse +// clicks at the element's center. + +import { sleep } from './extension.mjs'; + +const HOST = 'proscan-dock-host'; + +function findHost(node) { + const a = node.attributes || []; + for (let i = 0; i < a.length; i += 2) if (a[i] === 'id' && a[i + 1] === HOST) return node; + for (const c of node.children || []) { + const f = findHost(c); + if (f) return f; + } + return null; +} + +export async function dock(page) { + const cdp = await page.context().newCDPSession(page); + + async function shadowId() { + const { root } = await cdp.send('DOM.getDocument', { depth: -1, pierce: true }); + const host = findHost(root); + return host && host.shadowRoots && host.shadowRoots[0] ? host.shadowRoots[0].nodeId : null; + } + + // A re-render between getDocument and querySelector drops the node ids, + // so a lookup that races one tries again with a fresh document. + async function query(selector) { + for (let i = 0; ; i++) { + try { + const id = await shadowId(); + if (!id) return null; + const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: id, selector }); + return nodeId || null; + } catch (e) { + if (i >= 2 || !/Could not find node/.test(e.message)) throw e; + } + } + } + + async function call(selector, fn) { + const nodeId = await query(selector); + if (!nodeId) return undefined; + const { object } = await cdp.send('DOM.resolveNode', { nodeId }); + const { result } = await cdp.send('Runtime.callFunctionOn', { + objectId: object.objectId, functionDeclaration: fn, returnByValue: true, + }); + return result.value; + } + + const api = { + /** True when the page has a dock host at all. */ + async present() { + return page.evaluate((id) => !!document.getElementById(id), HOST); + }, + has: async (selector) => !!(await query(selector)), + text: async (selector = '.dock') => { + const t = await call(selector, 'function () { return this.textContent; }'); + return t === undefined ? null : t.replace(/\s+/g, ' ').trim(); + }, + attr: (selector, name) => call(selector, `function () { return this.getAttribute(${JSON.stringify(name)}); }`), + count: async (selector) => { + const id = await shadowId(); + if (!id) return 0; + const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: id, selector }); + return nodeIds.length; + }, + async box(selector) { + const nodeId = await query(selector); + if (!nodeId) return null; + const { model } = await cdp.send('DOM.getBoxModel', { nodeId }); + const q = model.border; + const xs = [q[0], q[2], q[4], q[6]]; + const ys = [q[1], q[3], q[5], q[7]]; + return { x: Math.min(...xs), y: Math.min(...ys), width: Math.max(...xs) - Math.min(...xs), height: Math.max(...ys) - Math.min(...ys) }; + }, + async click(selector) { + const b = await api.box(selector); + if (!b) throw new Error(`dock has no ${selector}`); + await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2); + }, + /** Focuses the element as a keyboard user would, so :focus-visible shows. */ + async focus(selector) { + await page.keyboard.press('Shift'); + const nodeId = await query(selector); + if (!nodeId) throw new Error(`dock has no ${selector}`); + await cdp.send('DOM.focus', { nodeId }); + }, + async waitFor(selector, { timeout = 15000 } = {}) { + const end = Date.now() + timeout; + while (Date.now() < end) { + if (await query(selector)) return true; + await sleep(150); + } + throw new Error(`dock never showed ${selector}`); + }, + async waitForText(re, { selector = '.dock', timeout = 15000 } = {}) { + const end = Date.now() + timeout; + let last = null; + while (Date.now() < end) { + last = await api.text(selector); + if (last && re.test(last)) return last; + await sleep(150); + } + throw new Error(`dock text never matched ${re}; last: ${last}`); + }, + }; + return api; +} diff --git a/tests/e2e/upgrade.spec.mjs b/tests/e2e/upgrade.spec.mjs index 6a694c1..995d71a 100644 --- a/tests/e2e/upgrade.spec.mjs +++ b/tests/e2e/upgrade.spec.mjs @@ -3,8 +3,10 @@ // auto-update does. From the live store build (v2.0, commit 7c2ba1c), and // from 2.1 (commit 40be432) in the middle of a run. Based on the audit's // upgrade harness. +import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; import { test, expect } from '@playwright/test'; import { launch, getState, waitForState, sleep, checkoutRevision, copyDir, enableDeveloperMode, clickStart, @@ -17,7 +19,8 @@ const V21_REV = '40be432'; const V20 = path.join(BUILD_DIR, 'v2.0'); const V21 = path.join(BUILD_DIR, 'v2.1'); const SLOT = path.join(BUILD_DIR, 'upgrade-slot'); -const CURRENT_VERSION = '2.3.0'; +// The build under test, whatever the manifest says it is. +const CURRENT_VERSION = JSON.parse(fs.readFileSync(fileURLToPath(new URL('../../manifest.json', import.meta.url)), 'utf8')).version; function bug(fid, what) { test.fail(!process.env.PROSCAN_SHOW_KNOWN, `${fid}: ${what}`); diff --git a/tests/setup/engine-rig.js b/tests/setup/engine-rig.js index 623f00d..b521a3f 100644 --- a/tests/setup/engine-rig.js +++ b/tests/setup/engine-rig.js @@ -73,6 +73,7 @@ function createRig({ site, flags, local = {}, now = () => Date.now(), random = ( tabs: { sendMessage(tabId, message, opts, cb) { const tab = tabs.get(tabId); + if (tab) (tab.inbox = tab.inbox || []).push(message); setImmediate(() => { if (!tab || !tab.listener) return withError('Could not establish connection. Receiving end does not exist.', () => cb()); let answered = false; @@ -113,6 +114,10 @@ function createRig({ site, flags, local = {}, now = () => Date.now(), random = ( router.on('HEARTBEAT', (m, s) => engine.heartbeat(m, s)); router.on('GET_RESULTS', () => engine.getResults()); router.on('SPREAD_RESULT', (m) => engine.spreadResult(m)); + router.on('START_RUN_HERE', (m, s) => engine.startHere(m, s)); + router.on('STOP_RUN_HERE', () => engine.stop()); + router.on('RUN_STATUS', (m, s) => engine.status(s)); + router.on('SAVE_SETTINGS', (m) => engine.saveSettings(m)); worker = { engine, router, timers }; return worker; } @@ -185,6 +190,12 @@ function createRig({ site, flags, local = {}, now = () => Date.now(), random = ( /** Messages of these types from tabs are lost, as when the worker is not up. */ drop(...types) { dropped.clear(); types.forEach((t) => dropped.add(t)); }, tabCtx: (id) => tabs.get(id).ctx, + /** A content script message to the worker from tab `tabId`, as a promise. */ + tab(tabId, message) { + return new Promise((resolve) => fromTab(tabId, message, (r) => resolve(r === undefined ? null : r))); + }, + /** Messages the worker sent to tab `tabId` that the page did not handle. */ + toTab: (tabId) => (tabs.get(tabId) && tabs.get(tabId).inbox) || [], /** A popup message to the worker. */ popup(message) { return new Promise((resolve) => { diff --git a/tests/unit/ai-key.test.js b/tests/unit/ai-key.test.js index 6835d28..3676fc3 100644 --- a/tests/unit/ai-key.test.js +++ b/tests/unit/ai-key.test.js @@ -59,7 +59,7 @@ test('remove deletes the key', async () => { await chrome.storage.local.set({ geminiApiKey: KEY }); await AiKey.init(document); expect(document.getElementById('geminiKeyInput').value).toBe(''); - expect(document.getElementById('geminiKeyInput').placeholder).toMatch(/Key saved/); + expect(document.getElementById('geminiKeyInput').placeholder).toMatch(/replace it/); document.getElementById('geminiKeyClear').click(); await flush(); expect(chrome.storage.local._getStore().geminiApiKey).toBeUndefined(); diff --git a/tests/unit/chat.test.js b/tests/unit/chat.test.js index a40c5e8..ac5c549 100644 --- a/tests/unit/chat.test.js +++ b/tests/unit/chat.test.js @@ -245,3 +245,10 @@ describe('cleanText', () => { expect(/[\u200b-\u200f\u202a-\u202e\u2066-\u2069]/.test(src)).toBe(false); }); }); + +test('a storefront scan is labeled by its name when the run has one', () => { + const named = Chat.buildContext({ ...baseData, scrapeRunMeta: { type: 'storefront', sellerId: 'A1B2C3', name: 'Northfield Goods' } }); + expect(named.source.label).toBe('Northfield Goods'); + const bare = Chat.buildContext({ ...baseData, scrapeRunMeta: { type: 'storefront', sellerId: 'A1B2C3' } }); + expect(bare.source.label).toBe('storefront A1B2C3'); +}); diff --git a/tests/unit/chatbot.test.js b/tests/unit/chatbot.test.js deleted file mode 100644 index f87397d..0000000 --- a/tests/unit/chatbot.test.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * @fileoverview Tests for the chat widget in scripts/content/chatbot.js. - * The widget must never see the key, must point at the real setting when no - * key is set, and must render model output as text (F-62, F-65). - */ - -const fs = require('fs'); -const path = require('path'); - -const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'scripts', 'content', 'chatbot.js'), 'utf8'); -const flush = () => new Promise(r => setTimeout(r, 0)); - -let shadow; -let sent; - -function mount(replies) { - document.body.innerHTML = '
'; - sent = []; - chrome.runtime.id = 'test-extension'; - chrome.runtime.sendMessage = jest.fn(async (msg) => { - sent.push(msg); - const r = replies[msg.type]; - if (r instanceof Error) throw r; - return typeof r === 'function' ? r(msg) : r; - }); - // Force an open root so the test can look inside. - const attach = Element.prototype.attachShadow; - jest.spyOn(Element.prototype, 'attachShadow').mockImplementation(function () { - shadow = attach.call(this, { mode: 'open' }); - return shadow; - }); - // eslint-disable-next-line no-eval - (0, eval)(SRC); -} - -async function ask(text) { - shadow.getElementById('ps-input').value = text; - shadow.getElementById('ps-send').click(); - await flush(); - await flush(); - const bubbles = shadow.querySelectorAll('.proscan-bubble'); - return bubbles[bubbles.length - 1]; -} - -afterEach(() => { - jest.restoreAllMocks(); - delete chrome.runtime.id; -}); - -test('with no key the greeting points at the popup setting', async () => { - mount({ CHAT_STATUS: { hasKey: false, productCount: 12 } }); - await flush(); - expect(shadow.getElementById('ps-greeting').textContent).toMatch(/ProScan popup.*AI chat settings/); - expect(shadow.getElementById('ps-badge').textContent).toBe('12 products'); -}); - -test('the greeting names the run the chat covers', async () => { - mount({ CHAT_STATUS: { hasKey: true, productCount: 48, source: 'search "usb c cable"', pages: 2 } }); - await flush(); - expect(shadow.getElementById('ps-greeting').textContent) - .toBe('Ask me about the 48 products from your last ProScan scan (search "usb c cable", 2 pages).'); -}); - -test('sends only the question and history, never products or a key', async () => { - mount({ CHAT_STATUS: { hasKey: true, productCount: 1 }, CHAT_MESSAGE: { answer: 'first' } }); - await ask('cheapest?'); - await ask('and the next one?'); - const chats = sent.filter(m => m.type === 'CHAT_MESSAGE'); - expect(Object.keys(chats[0]).sort()).toEqual(['history', 'question', 'type']); - expect(chats[0].history).toEqual([]); - expect(chats[1].history).toEqual([{ role: 'user', text: 'cheapest?' }, { role: 'model', text: 'first' }]); -}); - -test('model output is rendered as text, not HTML', async () => { - const evil = 'bold'; - mount({ CHAT_STATUS: { hasKey: true, productCount: 1 }, CHAT_MESSAGE: { answer: evil } }); - const bubble = await ask('hi'); - expect(bubble.textContent).toBe(evil); - expect(bubble.querySelector('img')).toBeNull(); - expect(bubble.querySelector('b')).toBeNull(); -}); - -test('a worker error is shown as the worker wrote it', async () => { - mount({ CHAT_STATUS: { hasKey: false, productCount: 0 }, CHAT_MESSAGE: { error: 'AI chat needs your own Gemini API key.' } }); - const bubble = await ask('hi'); - expect(bubble.textContent).toBe('AI chat needs your own Gemini API key.'); - expect(bubble.className).toMatch(/error/); -}); - -test('an orphaned script after an update says to reload, not to fix a key', async () => { - mount({ CHAT_STATUS: { hasKey: true, productCount: 1 }, CHAT_MESSAGE: new Error('Extension context invalidated.') }); - delete chrome.runtime.id; - const bubble = await ask('hi'); - expect(bubble.textContent).toBe('ProScan was updated. Reload this page to use the chat.'); -}); diff --git a/tests/unit/dock.test.js b/tests/unit/dock.test.js new file mode 100644 index 0000000..a83232d --- /dev/null +++ b/tests/unit/dock.test.js @@ -0,0 +1,270 @@ +/** + * @jest-environment node + * + * The on-page dock (scripts/content/dock.js) in a JSDOM page per test. + * Its shadow root is forced open here so the test can look inside. + */ +const fs = require('fs'); +const path = require('path'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); +const SRC = [ + 'scripts/modules/price.js', 'scripts/lib/parsers.js', 'scripts/lib/run.js', 'scripts/lib/page-kind.js', + 'scripts/content/dock-styles.js', 'scripts/content/dock.js', +].map(read).join('\n;\n'); +const YOGA = read('tests/pages/2026-09/search-yoga-mat.html'); +const PRODUCT = read('tests/pages/2026-09/product-dp.html'); + +const flush = async (n = 6) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); }; + +const IDLE = { run: null, count: 0, settings: { maxPages: 20, suggest: true }, hasKey: false, signedIn: false, cloudSync: true }; + +function mount(url, html, replies = {}, { session = {} } = {}) { + const vc = new VirtualConsole(); + const dom = new JSDOM(html, { url, runScripts: 'outside-only', virtualConsole: vc }); + const w = dom.window; + windows.push(w); + Object.defineProperty(w.HTMLElement.prototype, 'innerText', { + get() { return this.textContent; }, set(v) { this.textContent = v; }, configurable: true, + }); + Object.entries(session).forEach(([k, v]) => w.sessionStorage.setItem(k, v)); + const out = { w, sent: [], listeners: [], shadow: null, spread: [] }; + const attach = w.Element.prototype.attachShadow; + w.Element.prototype.attachShadow = function () { out.shadow = attach.call(this, { mode: 'open' }); return out.shadow; }; + w.chrome = { + runtime: { + id: 'test-extension', + lastError: null, + getManifest: () => ({ version: '2.4.0' }), + sendMessage(msg, cb) { + out.sent.push(msg); + const r = replies[msg.type]; + Promise.resolve(typeof r === 'function' ? r(msg) : r).then((v) => setImmediate(() => cb(v))); + }, + onMessage: { addListener: (fn) => out.listeners.push(fn) }, + }, + }; + w.watchSpread = (fn) => { out.spread.push(fn); return () => {}; }; + w.runSpreadAnalysis = jest.fn(); + w.stopSpreadAnalysis = jest.fn(); + w.eval(SRC); + out.$ = (sel) => out.shadow && out.shadow.querySelector(sel); + out.$$ = (sel) => (out.shadow ? [...out.shadow.querySelectorAll(sel)] : []); + out.text = () => (out.shadow ? out.shadow.textContent.replace(/\s+/g, ' ') : ''); + out.types = () => out.sent.map((m) => m.type); + return out; +} + +const SEARCH = 'https://www.amazon.com/s?k=yoga+mat'; + +const windows = []; +afterEach(() => { windows.splice(0).forEach((w) => w.close()); }); + +test('a search page suggests Scrape, and one click starts a run in this tab', async () => { + let started = false; + const d = mount(SEARCH, YOGA, { + RUN_STATUS: () => (started + ? { ...IDLE, run: { state: 'running', page: 0, maxPages: 20, itemCount: 0, thisTab: true, source: { type: 'keyword', keyword: 'yoga mat' } } } + : IDLE), + START_RUN_HERE: () => { started = true; return { ok: true, runId: 'r1' }; }, + }); + await flush(); + expect(d.$('.launcher.suggest')).not.toBeNull(); + expect(d.text()).toContain('"yoga mat"'); + expect(d.$('.launch-text span').textContent).toMatch(/^\d+ products$/); + + d.$('[data-k="scrape-quick"]').click(); + await flush(); + const start = d.sent.find((m) => m.type === 'START_RUN_HERE'); + expect(start).toBeDefined(); + expect(start).not.toHaveProperty('tabId'); + expect(d.$('.card')).not.toBeNull(); + expect(d.$('.num').textContent).toBe('0 products'); + expect(d.text()).toContain('Page 1 of 20'); + expect(d.$('[data-k="stop"]').getAttribute('aria-label')).toBe('Stop and keep 0 products'); +}); + +test('a product page gets the plain launcher with Ask and no Scrape', async () => { + const d = mount('https://www.amazon.com/dp/B09B8V1LZ3', PRODUCT, { RUN_STATUS: IDLE }); + await flush(); + expect(d.$('.launcher.plain')).not.toBeNull(); + expect(d.$('[data-k="scrape-quick"]')).toBeNull(); + expect(d.$('[data-k="chat"]').getAttribute('aria-label')).toBe('Ask about products'); + d.$('[data-k="launcher"]').click(); + await flush(); + expect(d.text()).toContain('Open a search or a storefront'); + expect(d.$('[data-k="scrape"]')).toBeNull(); + expect(d.types()).not.toContain('START_RUN_HERE'); +}); + +test('cart, checkout and sign-in pages get no dock at all', async () => { + for (const url of ['https://www.amazon.com/gp/cart/view.html', 'https://www.amazon.com/checkout/p/p-1/spc', 'https://www.amazon.com/ap/signin']) { + const d = mount(url, '', { RUN_STATUS: IDLE }); + await flush(); + expect(d.w.document.getElementById('proscan-dock-host')).toBeNull(); + expect(d.sent).toEqual([]); + } +}); + +test('Not now hides the suggestion for the rest of the tab session', async () => { + const d = mount(SEARCH, YOGA, { RUN_STATUS: IDLE }); + await flush(); + d.$('[data-k="notnow"]').click(); + await flush(); + expect(d.w.sessionStorage.getItem('proscan.dock.notNow')).toBe('1'); + expect(d.$('.launcher.plain')).not.toBeNull(); + + const again = mount(SEARCH, YOGA, { RUN_STATUS: IDLE }, { session: { 'proscan.dock.notNow': '1' } }); + await flush(); + expect(again.$('.launcher.plain')).not.toBeNull(); + expect(again.$('[data-k="scrape-quick"]')).toBeNull(); +}); + +test('the Suggest Scrape setting turned off keeps the launcher plain', async () => { + const d = mount(SEARCH, YOGA, { RUN_STATUS: { ...IDLE, settings: { maxPages: 20, suggest: false } } }); + await flush(); + expect(d.$('.launcher.plain')).not.toBeNull(); +}); + +test('a seller profile points at its storefront and never starts a run there', async () => { + const d = mount('https://www.amazon.com/sp?seller=A1B2C3D4E5F6G7', '

Northfield Goods

', { RUN_STATUS: IDLE }); + await flush(); + const link = d.$('[data-k="storefront"]'); + expect(link.getAttribute('href')).toBe('https://www.amazon.com/s?me=A1B2C3D4E5F6G7&marketplaceID=ATVPDKIKX0DER'); + expect(d.text()).toContain('Northfield Goods'); +}); + +test('a running run minimizes to page x of y with a labeled Stop', async () => { + const running = { ...IDLE, run: { state: 'running', page: 3, maxPages: 10, itemCount: 181, thisTab: true, startedAt: Date.now() - 30000, source: { type: 'storefront', sellerId: 'A1B2C3D4E5F6G7' } } }; + const d = mount(SEARCH, YOGA, { RUN_STATUS: running, STOP_RUN_HERE: { ok: true, stopped: true } }); + await flush(); + expect(d.text()).toContain('Page 4 of 10'); + expect(d.text()).toContain('181 products so far'); + const stop = d.$('[data-k="stop"]'); + expect(stop.textContent).toContain('Stop'); + expect(stop.getAttribute('aria-label')).toBe('Stop scraping and keep 181 products'); + stop.click(); + await flush(); + expect(d.types()).toContain('STOP_RUN_HERE'); +}); + +test('the worker saying a page was saved refreshes the dock', async () => { + let page = 1; + const d = mount(SEARCH, YOGA, { + RUN_STATUS: () => ({ ...IDLE, run: { state: 'running', page, maxPages: 5, itemCount: page * 48, thisTab: true, source: { type: 'keyword', keyword: 'yoga mat' } } }), + }); + await flush(); + expect(d.text()).toContain('Page 2 of 5'); + page = 2; + d.listeners.forEach((fn) => fn({ type: 'RUN_PROGRESS', page: 2 })); + await flush(); + expect(d.text()).toContain('Page 3 of 5'); + expect(d.text()).toContain('96 products so far'); +}); + +test('a run that ran out of pages hatches the rest and says so', async () => { + const done = { + ...IDLE, count: 912, + run: { state: 'done', reason: 'complete', page: 19, maxPages: 20, itemCount: 912, thisTab: true, finishedAt: Date.now(), source: { type: 'keyword', keyword: 'yoga mat' } }, + summary: { medianCents: 2740, avgRating: 4.5, sponsoredPct: 12 }, spreadCount: 0, + }; + const d = mount(SEARCH, YOGA, { RUN_STATUS: done, DOWNLOAD: { ok: true, via: 'downloads', filename: 'x.xlsx' } }, { session: { 'proscan.dock.open': '1' } }); + await flush(); + expect(d.$('.num').textContent).toBe('912 products'); + expect(d.$$('.ticks i')).toHaveLength(20); + expect(d.$$('.ticks i.done')).toHaveLength(19); + expect(d.text()).not.toContain('$27.40'); + expect(d.$('[data-k="csv"]')).toBeNull(); + d.$('[data-k="xlsx"]').click(); + await flush(); + expect(d.sent.find((m) => m.type === 'DOWNLOAD')).toEqual({ type: 'DOWNLOAD', format: 'xlsx' }); + expect(d.$('.toast').textContent).toBe('Excel downloaded'); + expect(d.$('.card .notice')).toBeNull(); +}); + +test('a file too big for the worker to hand over is saved from the page', async () => { + const done = { ...IDLE, count: 2, run: { state: 'stopped', reason: 'stopped', page: 1, maxPages: 20, itemCount: 2, thisTab: true, finishedAt: Date.now(), source: {} }, summary: null }; + const d = mount(SEARCH, YOGA, { + RUN_STATUS: done, + DOWNLOAD: { ok: true, via: 'page', filename: 'big.csv', mime: 'text/csv', base64: Buffer.from('a,b\r\n').toString('base64') }, + }, { session: { 'proscan.dock.open': '1' } }); + d.w.URL.createObjectURL = jest.fn(() => 'blob:x'); + d.w.URL.revokeObjectURL = jest.fn(); + const clicks = []; + d.w.HTMLAnchorElement.prototype.click = function () { clicks.push([this.getAttribute('download'), this.href]); }; + await flush(); + expect(d.$('.num').textContent).toBe('2 products'); + expect(d.$('.meta').textContent).toBe('Stopped'); + d.$('[data-k="formats"]').click(); + expect(d.$('[data-k="formats"]').getAttribute('aria-expanded')).toBe('true'); + d.$('[data-k="csv"]').click(); + await flush(); + expect(clicks).toEqual([['big.csv', 'blob:x']]); +}); + +test('Ask with no key sends the user to ProScan settings, never asks for the key here', async () => { + const d = mount(SEARCH, YOGA, { + RUN_STATUS: IDLE, CHAT_STATUS: { hasKey: false, productCount: 12, source: 'search "yoga mat"' }, OPEN_SETTINGS: { ok: true }, + }); + await flush(); + d.$('[data-k="chat"]').click(); + await flush(); + expect(d.$('.scope').textContent).toBe('12 products'); + expect(d.$$('input[type="password"]')).toEqual([]); + d.$('[data-k="add-key"]').click(); + await flush(); + expect(d.sent.find((m) => m.type === 'OPEN_SETTINGS')).toEqual({ type: 'OPEN_SETTINGS', section: 'key' }); +}); + +test('chat answers are text, and only the question and history go out', async () => { + const evil = 'bold'; + const d = mount(SEARCH, YOGA, { + RUN_STATUS: IDLE, CHAT_STATUS: { hasKey: true, productCount: 3, source: 'search "yoga mat"' }, CHAT_MESSAGE: { answer: evil }, + }); + await flush(); + d.$('[data-k="chat"]').click(); + await flush(); + const input = d.$('[data-k="compose"]'); + input.value = 'cheapest?'; + input.dispatchEvent(new d.w.Event('input')); + d.$('[data-k="send"]').click(); + await flush(); + const msgs = d.$$('.msg'); + expect(msgs[msgs.length - 1].textContent).toBe(evil); + expect(d.$('.msg img')).toBeNull(); + const chat = d.sent.find((m) => m.type === 'CHAT_MESSAGE'); + expect(Object.keys(chat).sort()).toEqual(['history', 'question', 'type']); +}); + +test('every icon-only control has a label, and Escape closes the card', async () => { + const d = mount(SEARCH, YOGA, { RUN_STATUS: IDLE }); + await flush(); + const unlabeled = () => d.$$('button, a').filter((b) => !b.textContent.trim() && !b.getAttribute('aria-label') && !b.getAttribute('aria-labelledby')); + expect(unlabeled()).toEqual([]); + d.$('[data-k="launcher"]').click(); + await flush(); + expect(unlabeled()).toEqual([]); + d.$('[data-k="gear"]').click(); + await flush(); + expect(unlabeled()).toEqual([]); + expect(d.text()).toContain('Pages per run'); + expect(d.$$('input[type="password"], input[type="email"]')).toEqual([]); + + d.$('.dock').dispatchEvent(new d.w.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await flush(); + expect(d.$('.card')).toBeNull(); + expect(d.shadow.activeElement.getAttribute('data-k')).toBe('launcher'); +}); + +test('keys typed in the dock do not reach the page', async () => { + const d = mount(SEARCH, YOGA, { RUN_STATUS: IDLE, CHAT_STATUS: { hasKey: true, productCount: 3 } }); + const seen = []; + d.w.document.addEventListener('keydown', (e) => seen.push(e.key)); + await flush(); + d.$('[data-k="chat"]').click(); + await flush(); + d.$('[data-k="compose"]').dispatchEvent(new d.w.KeyboardEvent('keydown', { key: '/', bubbles: true, composed: true })); + expect(seen).toEqual([]); +}); diff --git a/tests/unit/download.test.js b/tests/unit/download.test.js new file mode 100644 index 0000000..7b7ae64 --- /dev/null +++ b/tests/unit/download.test.js @@ -0,0 +1,66 @@ +/** + * @jest-environment node + * + * DOWNLOAD from the page: the worker makes the file with the popup's + * Exporter.build and hands it to chrome.downloads, or back to the page + * when it is too big for a data: URL. + */ +global.XLSX = require('../../libs/xlsx.full.min.js'); +global.Analyzer = require('../../scripts/modules/analyzer'); +global.SpreadAnalyzer = require('../../scripts/modules/spread-analyzer'); +const Exporter = require('../../scripts/modules/exporter'); +const Download = require('../../scripts/background/download'); + +const rows = [ + { name: 'Mat', asin: 'B000000001', price: '$19.99', priceCents: 1999, rating: 4.5, reviewCount: 10, url: 'https://www.amazon.com/dp/B000000001' }, + { name: 'Block', asin: 'B000000002', price: '$9.99', priceCents: 999, rating: 4.1, reviewCount: 3, url: 'https://www.amazon.com/dp/B000000002' }, +]; +const spread = { B000000001: { sellerPrices: [18, 19.99, 25] } }; + +function deps(state, max) { + const calls = []; + return { + calls, + deps: { Exporter, getState: async () => state, download: async (o) => { calls.push(o); return 7; }, max }, + }; +} + +const decode = (b64) => Buffer.from(b64, 'base64'); + +test('a small CSV goes to chrome.downloads as a data URL with the save prompt', async () => { + const { deps: d, calls } = deps({ results: rows, spread }); + const out = await Download.deliver(d, 'csv'); + expect(out).toEqual({ ok: true, via: 'downloads', filename: expect.stringMatching(/\.csv$/) }); + expect(calls).toHaveLength(1); + expect(calls[0].saveAs).toBe(true); + expect(calls[0].url.startsWith('data:text/csv;base64,')).toBe(true); + const text = decode(calls[0].url.split(',')[1]).toString('utf8'); + expect(text.startsWith('"Product Name"')).toBe(true); + // Spread data rides along now (audit: exports left it out). + expect(text).toContain('"CV %"'); +}); + +test('Excel from the worker is a real xlsx file', async () => { + const { deps: d, calls } = deps({ results: rows, spread: {} }); + await Download.deliver(d, 'xlsx'); + expect(calls[0].url.startsWith(`data:${Download.FORMATS.xlsx};base64,`)).toBe(true); + expect(decode(calls[0].url.split(',')[1]).subarray(0, 2).toString()).toBe('PK'); +}); + +test('a file too big for a data URL goes back to the page', async () => { + const { deps: d, calls } = deps({ results: rows, spread: {} }, 10); + const out = await Download.deliver(d, 'json'); + expect(calls).toEqual([]); + expect(out).toMatchObject({ ok: true, via: 'page', mime: 'application/json', filename: expect.stringMatching(/\.json$/) }); + expect(JSON.parse(decode(out.base64).toString('utf8')).totalProducts).toBe(2); +}); + +test('nothing to save, or an unknown format, says so', async () => { + expect(await Download.deliver(deps({ results: [], spread: {} }).deps, 'csv')).toMatchObject({ error: 'empty' }); + expect(await Download.deliver(deps({ results: rows, spread: {} }).deps, 'exe')).toMatchObject({ error: 'format' }); +}); + +test('base64 of a large buffer matches Node', () => { + const bytes = new Uint8Array(200000).map((_, i) => (i * 31) % 256); + expect(Download.toBase64(bytes)).toBe(Buffer.from(bytes).toString('base64')); +}); diff --git a/tests/unit/engine-dock.test.js b/tests/unit/engine-dock.test.js new file mode 100644 index 0000000..9cf9e9e --- /dev/null +++ b/tests/unit/engine-dock.test.js @@ -0,0 +1,168 @@ +/** + * @jest-environment node + * + * The dock's messages to the run engine: a run started from the page is + * bound to the page's own tab, RUN_STATUS gives the run in brief and never + * the products or the key, and the run's tab hears about each saved page. + */ +const { createRig, settle } = require('../setup/engine-rig'); +const { summarize, brief, storeNameOf } = require('../../scripts/background/engine'); +const Run = require('../../scripts/lib/run'); + +const asinFor = (k, p, i) => `B0${k.slice(0, 3).toUpperCase().padEnd(3, 'X')}${String(p).padStart(2, '0')}${String(i).padStart(3, '0')}`; +const card = (asin, price) => ` +
+

Item ${asin}

+
${price}
+
4.5 out of 5 stars
+
`; +const pageNo = (url) => Number(new URL(url).searchParams.get('page') || 1); +function site(pages) { + return (url) => { + const u = new URL(url); + const k = u.searchParams.get('k') || u.searchParams.get('me'); + const p = pageNo(url); + if (!k || p > pages) return null; + const cards = [0, 1, 2, 3].map((i) => card(asinFor(k, p, i), `$${10 + i}.00`)).join(''); + const q = u.searchParams.get('me') ? `me=${k}` : `k=${k}`; + const next = p < pages + ? `Next` + : 'Next'; + return `
${cards}
${next}`; + }; +} + +async function runUntilEnd(rig, ms = 30000) { + for (let t = 0; t < ms; t += 500) { + const run = await rig.run(); + if (run && !Run.isActive(run)) return run; + await settle(500); + } + return rig.run(); +} + +beforeEach(() => jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] })); +afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); +}); + +test('START_RUN_HERE runs in the sender tab, whatever tab id the message names', async () => { + const rig = createRig({ site: site(2) }); + const other = rig.openTab('https://www.amazon.com/s?k=other'); + const tab = rig.openTab('https://www.amazon.com/s?k=here'); + await settle(); + const resp = await rig.tab(tab, { type: 'START_RUN_HERE', tabId: other }); + expect(resp).toMatchObject({ ok: true }); + expect((await rig.run()).tabId).toBe(tab); + const run = await runUntilEnd(rig); + expect(run).toMatchObject({ state: 'done', reason: 'complete', page: 2, itemCount: 8 }); +}); + +test('a page count sent with Scrape becomes the setting and caps the run', async () => { + const rig = createRig({ site: site(5), local: { settings: { maxPages: 20 } } }); + const tab = rig.openTab('https://www.amazon.com/s?me=A1B2C3D4E5F6G7'); + await settle(); + await rig.tab(tab, { type: 'START_RUN_HERE', maxPages: 2 }); + const run = await runUntilEnd(rig); + expect(run).toMatchObject({ reason: 'complete', page: 2, maxPages: 2 }); + expect(rig.local.dump().settings.maxPages).toBe(2); + expect(run.source).toMatchObject({ type: 'storefront', sellerId: 'A1B2C3D4E5F6G7' }); +}); + +test('the popup-only START_RUN is still refused from a page', async () => { + const rig = createRig({ site: site(2) }); + const tab = rig.openTab('https://www.amazon.com/s?k=nope'); + await settle(); + expect(await rig.tab(tab, { type: 'START_RUN', tabId: tab })).toBeNull(); + expect(await rig.run()).toBeNull(); +}); + +test('RUN_STATUS says whose run it is and leaves out products and the key', async () => { + const rig = createRig({ site: site(3), local: { geminiApiKey: 'AIzaSECRETSECRETSECRET00', account: { uid: 'u1', email: 'a@b.c' } } }); + const tab = rig.openTab('https://www.amazon.com/s?k=mine'); + const other = rig.openTab('https://www.amazon.com/s?k=theirs'); + await settle(); + await rig.tab(tab, { type: 'START_RUN_HERE' }); + await settle(100); + + const mine = await rig.tab(tab, { type: 'RUN_STATUS' }); + const theirs = await rig.tab(other, { type: 'RUN_STATUS' }); + expect(mine.run).toMatchObject({ state: 'running', thisTab: true, maxPages: 20 }); + expect(theirs.run).toMatchObject({ state: 'running', thisTab: false }); + expect(mine).toMatchObject({ hasKey: true, signedIn: true, settings: { maxPages: 20, suggest: true } }); + const text = JSON.stringify(mine); + expect(text).not.toMatch(/SECRET|a@b\.c|B0MIN/); + expect(mine.run).not.toHaveProperty('tabId'); + + const run = await runUntilEnd(rig); + const done = await rig.tab(tab, { type: 'RUN_STATUS' }); + expect(done.run).toMatchObject({ runId: run.runId, state: 'done', reason: 'complete', page: 3, thisTab: true }); + expect(done.count).toBe(12); + expect(done.summary).toEqual({ medianCents: 1150, avgRating: 4.5, sponsoredPct: 0 }); + expect(JSON.stringify(done)).not.toMatch(/B0MIN/); +}); + +test('STOP_RUN_HERE stops the run and keeps what it saved', async () => { + const rig = createRig({ site: site(4) }); + const tab = rig.openTab('https://www.amazon.com/s?k=halt'); + await settle(); + await rig.tab(tab, { type: 'START_RUN_HERE' }); + await settle(200); + expect((await rig.run()).page).toBe(1); + expect(await rig.tab(tab, { type: 'STOP_RUN_HERE' })).toMatchObject({ ok: true, stopped: true }); + expect(await rig.run()).toMatchObject({ state: 'stopped', reason: 'stopped', itemCount: 4 }); +}); + +test('the run tab hears about every saved page it waits on', async () => { + const rig = createRig({ site: site(3) }); + const tab = rig.openTab('https://www.amazon.com/s?k=news'); + await settle(); + await rig.tab(tab, { type: 'START_RUN_HERE' }); + await runUntilEnd(rig); + const progress = rig.toTab(tab).filter((m) => m.type === 'RUN_PROGRESS'); + expect(progress.map((m) => [m.page, m.itemCount, m.maxPages])).toEqual([[1, 4, 20], [2, 8, 20]]); + expect(rig.toTab(tab).some((m) => m.type === 'RUN_ENDED')).toBe(true); +}); + +test('SAVE_SETTINGS clamps the page count and keeps other settings', async () => { + const rig = createRig({ site: site(1), local: { settings: { maxPages: 20, pageDelay: 2000 } } }); + const tab = rig.openTab('https://www.amazon.com/s?k=cfg'); + await settle(); + expect(await rig.tab(tab, { type: 'SAVE_SETTINGS', maxPages: 9999, suggest: false })) + .toMatchObject({ settings: { maxPages: 400, suggest: false } }); + expect(rig.local.dump().settings).toEqual({ maxPages: 400, pageDelay: 2000, suggest: false }); + await rig.tab(tab, { type: 'SAVE_SETTINGS', maxPages: 'x' }); + expect(rig.local.dump().settings.maxPages).toBe(Run.DEFAULT_MAX_PAGES); +}); + +describe('summarize', () => { + test('median price, average rating and sponsored share', () => { + expect(summarize([ + { priceCents: 1000, rating: 4, sponsored: true }, + { priceCents: 3000, rating: 5 }, + { priceCents: null, rating: null }, + { priceCents: 2000 }, + ])).toEqual({ medianCents: 2000, avgRating: 4.5, sponsoredPct: 25 }); + }); + test('nothing known gives nulls', () => { + expect(summarize([])).toEqual({ medianCents: null, avgRating: null, sponsoredPct: null }); + }); +}); + +test('brief keeps the run shape small', () => { + const run = { runId: 'r', tabId: 3, state: 'running', page: 2, maxPages: 10, itemCount: 5, source: { type: 'keyword', keyword: 'x', url: 'u' } }; + expect(brief(run, 3)).toEqual({ + runId: 'r', state: 'running', reason: null, page: 2, maxPages: 10, itemCount: 5, startedAt: null, finishedAt: null, + source: { type: 'keyword', sellerId: null, keyword: 'x', name: null }, thisTab: true, + }); + expect(brief(null, 3)).toBeNull(); +}); + +test('storeNameOf reads a storefront name from its tab title', () => { + expect(storeNameOf('Amazon.com: Northfield Goods')).toBe('Northfield Goods'); + expect(storeNameOf('Amazon.com: Cedar‮ & Pine')).toBe('Cedar & Pine'); + expect(storeNameOf('Amazon.com')).toBeNull(); + expect(storeNameOf('x'.repeat(61))).toBeNull(); + expect(storeNameOf(undefined)).toBeNull(); +}); diff --git a/tests/unit/page-kind.test.js b/tests/unit/page-kind.test.js new file mode 100644 index 0000000..3f43734 --- /dev/null +++ b/tests/unit/page-kind.test.js @@ -0,0 +1,86 @@ +const PageKind = require('../../scripts/lib/page-kind'); + +const A = 'https://www.amazon.com'; + +describe('pages that get the Scrape suggestion', () => { + test.each([ + [`${A}/s?k=yoga+mat`, 'search', { keyword: 'yoga mat' }], + [`${A}/s?k=usb+c&page=3&ref=sr_pg_3`, 'search', { keyword: 'usb c' }], + [`${A}/s?rh=n%3A3407731&fs=true`, 'search', { keyword: null }], + [`${A}/s?me=A1B2C3D4E5F6G7&marketplaceID=ATVPDKIKX0DER`, 'storefront', { sellerId: 'A1B2C3D4E5F6G7' }], + [`${A}/s?me=a1b2c3d4e5f6g7&k=lamp`, 'storefront', { sellerId: 'A1B2C3D4E5F6G7' }], + [`https://smile.amazon.com/s?k=desk`, 'search', {}], + ])('%s is %s', (url, kind, extra) => { + const got = PageKind.classify(url); + expect(got).toMatchObject({ kind, ...extra }); + expect(PageKind.scrapable(url)).toBe(true); + expect(PageKind.suggests(url)).toBe(true); + }); + + test('a seller profile points at its storefront but cannot start a run itself', () => { + const url = `${A}/sp?ie=UTF8&seller=A1B2C3D4E5F6G7&isAmazonFulfilled=1`; + expect(PageKind.classify(url)).toMatchObject({ kind: 'seller', sellerId: 'A1B2C3D4E5F6G7' }); + expect(PageKind.suggests(url)).toBe(true); + expect(PageKind.scrapable(url)).toBe(false); + expect(PageKind.storefrontUrl('A1B2C3D4E5F6G7')).toBe(`${A}/s?me=A1B2C3D4E5F6G7&marketplaceID=ATVPDKIKX0DER`); + }); + + test('a brand store is a store page, not a scrapable one', () => { + const url = `${A}/stores/Gaiam/page/4F1C1B7A-6C0B-4D0B-9E1F-3B7B2C8D9E10`; + expect(PageKind.classify(url)).toMatchObject({ kind: 'store', sellerId: null }); + expect(PageKind.scrapable(url)).toBe(false); + }); +}); + +describe('pages that never get it', () => { + test.each([ + `${A}/dp/B09B8V1LZ3`, + `${A}/Gaiam-Thick-Yoga-Mat/dp/B09B8V1LZ3/ref=sr_1_1?k=yoga+mat`, + `${A}/gp/product/B09B8V1LZ3`, + `${A}/gp/aw/d/B09B8V1LZ3`, + `${A}/gp/offer-listing/B09B8V1LZ3`, + `${A}/gp/aod/ajax?asin=B09B8V1LZ3`, + `${A}/gp/cart/view.html?ref_=nav_cart`, + `${A}/cart`, + `${A}/cart/smart-wagon?newItems=1`, + `${A}/gp/buy/spc/handlers/display.html`, + `${A}/checkout/p/p-123/spc`, + `${A}/ap/signin?openid.return_to=%2Fs%3Fk%3Dmat`, + `${A}/gp/css/homepage.html`, + `${A}/gp/your-account/order-history`, + `${A}/your-orders/orders`, + `${A}/hz/wishlist/ls`, + `${A}/a/addresses`, + `${A}/cpe/yourpayments/wallet`, + 'https://example.com/s?k=yoga+mat', + 'not a url', + ])('%s', (url) => { + expect(PageKind.classify(url).kind).toBe('never'); + expect(PageKind.suggests(url)).toBe(false); + expect(PageKind.scrapable(url)).toBe(false); + }); + + test('the home page and a bare /s are quiet', () => { + for (const url of [`${A}/`, `${A}/s`, `${A}/gp/bestsellers`, `${A}/sp?seller=`]) { + expect(PageKind.suggests(url)).toBe(false); + } + }); + + test('a me= value that is not a seller id is not a storefront', () => { + expect(PageKind.classify(`${A}/s?me=