Add Excel export, rewrite Sheet pull to transposed layout, enrich battlecards
- SyncToSheet.vue: add "Download Excel" export alongside Copy rows, using exceljs (dynamically imported to avoid bundling ~940KB into AnalysePage) instead of xlsx (unpatched high-severity CVEs) - appscript/Sync.gs: rewrite the Sheet-pull layout from column-per-field/ row-per-competitor to the transposed layout (fields down column A, one column per competitor) — matches the dashboard's Copy rows/Excel export and the real legacy client reference sheet, which the old layout was never actually reconciled against. Row detection is now label-based and self-bootstraps a blank template on first pull. - CLAUDE.md: correct §15 to document the transposed layout instead of the stale column-per-field description - battlecard_service.py: replace two hardcoded placeholder strings (positioning_summary, recent_changes) with real computed data — actual price_history-derived recent changes, and richer product summaries (service levels, target audience, activities). Raise BATTLECARD_PRODUCT_LIMIT 10->50 and loosen prompt word caps so the richer input isn't compressed away - BattlecardCard.vue: render pricing_observation, generated but never displayed before - pb_migrations/009: fix battlecards.generated_at to stamp on update, not just create, so regenerated battlecards show an accurate "Updated" timestamp Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
a25909a459
commit
965028e6c2
171
CLAUDE.md
171
CLAUDE.md
|
|
@ -603,7 +603,7 @@ bettersight/
|
||||||
├── appscript/ # Sheet-initiated pull integration — 3 files
|
├── appscript/ # Sheet-initiated pull integration — 3 files
|
||||||
│ ├── Code.gs # onOpen(), install entry point
|
│ ├── Code.gs # onOpen(), install entry point
|
||||||
│ ├── Validation.gs # validateLicence() — email only
|
│ ├── Validation.gs # validateLicence() — email only
|
||||||
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectColumns()
|
│ ├── Sync.gs # pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||||
│ └── Sidebar.html # Tab selector + pull button + last-pull status
|
│ └── Sidebar.html # Tab selector + pull button + last-pull status
|
||||||
├── n8n/
|
├── n8n/
|
||||||
│ └── workflows/ # n8n workflow JSON exports
|
│ └── workflows/ # n8n workflow JSON exports
|
||||||
|
|
@ -2371,23 +2371,50 @@ Firecrawl is in the MVP stack for `/monitor` only.
|
||||||
|
|
||||||
## 11. Battlecard Generation
|
## 11. Battlecard Generation
|
||||||
|
|
||||||
After each scrape run completes, n8n calls `/internal/battlecard/<competitor_id>`.
|
After each scrape run completes (whether triggered by a manual dashboard
|
||||||
|
analysis or the nightly watchlist cron — see §7's "Trip watchlist"),
|
||||||
|
`jobs.py` calls `generate_battlecard()` directly whenever a competitor's
|
||||||
|
refresh path produced genuinely fresh data (real extraction, not a
|
||||||
|
cache reuse and not a hash-confirmed-unchanged result).
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def generate_battlecard(competitor_id: str, tenant_id: str) -> dict:
|
def generate_battlecard(competitor_id: str, tenant_id: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Pulls latest product data for a competitor from PocketBase.
|
Pulls a competitor's tracked products (up to BATTLECARD_PRODUCT_LIMIT
|
||||||
Passes structured data to Claude Haiku via OpenRouter.
|
= 50, newest first) from PocketBase, plus their real recent
|
||||||
Saves generated battlecard text and JSON back to PocketBase.
|
price_history, and passes a real structured summary to the
|
||||||
Returns battlecard dict for immediate use.
|
OpenRouter extraction model. Saves generated battlecard text and
|
||||||
|
JSON back to PocketBase. Returns battlecard dict for immediate use.
|
||||||
|
|
||||||
Data flow:
|
Data flow:
|
||||||
competitor_id → PocketBase products (latest 10) →
|
competitor_id → PocketBase products (latest 50) →
|
||||||
structured prompt → OpenRouter (claude-haiku) →
|
_summarise_products() (top trips, trip count, price range, avg
|
||||||
|
duration, service levels, target audiences, activities) +
|
||||||
|
_summarise_recent_price_changes() (real price_history moves in
|
||||||
|
the last 30 days, sorted by magnitude) →
|
||||||
|
structured prompt → OpenRouter (via core.extractor) →
|
||||||
battlecard text + JSON → PocketBase battlecards table
|
battlecard text + JSON → PocketBase battlecards table
|
||||||
"""
|
"""
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`BATTLECARD_PRODUCT_LIMIT` was previously 10 — skewed the summary
|
||||||
|
toward whichever products happened to be scraped most recently instead
|
||||||
|
of a reasonably complete view of the tracked catalogue. Raised to 50
|
||||||
|
(adjustable here only).
|
||||||
|
|
||||||
|
`{positioning_summary}` and `{recent_changes}` were previously hardcoded
|
||||||
|
placeholder strings (`'Derived from current product catalogue.'` and
|
||||||
|
`'See price_history for this competitor.'`) — the model was being told
|
||||||
|
those inputs existed when they never varied per competitor or per run.
|
||||||
|
`positioning_summary` is removed outright (it was also circular — the
|
||||||
|
prompt's own *output* already asks the model to derive `"positioning"`).
|
||||||
|
`recent_changes` is now a real sentence built from that competitor's
|
||||||
|
actual `price_history` rows within the last 30 days (every row already
|
||||||
|
represents a genuine price move — `_save_scrape_result()` only writes
|
||||||
|
one when the price actually changed — so no magnitude filter is needed,
|
||||||
|
just the recency window), falling back to "No significant price changes
|
||||||
|
in the last 30 days." when none qualify.
|
||||||
|
|
||||||
Prompt template:
|
Prompt template:
|
||||||
```
|
```
|
||||||
You are a competitive intelligence analyst for an adventure travel operator.
|
You are a competitive intelligence analyst for an adventure travel operator.
|
||||||
|
|
@ -2395,16 +2422,22 @@ Based on this competitor data, write a concise battlecard.
|
||||||
|
|
||||||
Competitor: {name}
|
Competitor: {name}
|
||||||
Top trips: {top_trips}
|
Top trips: {top_trips}
|
||||||
|
Number of tracked trips: {trip_count}
|
||||||
Price range: {price_low} - {price_high} USD
|
Price range: {price_low} - {price_high} USD
|
||||||
Avg duration: {avg_duration} days
|
Avg duration: {avg_duration} days
|
||||||
Positioning: {positioning_summary}
|
Service levels offered: {service_levels}
|
||||||
Recent changes: {recent_changes}
|
Typical target audience: {target_audiences}
|
||||||
|
Common activities/themes: {activities_summary}
|
||||||
|
Recent price changes (last 30 days): {recent_changes}
|
||||||
|
|
||||||
Write:
|
Write — ground every point in the specific data above (real prices,
|
||||||
1. One line positioning summary (max 15 words)
|
service levels, activities, audience, recent price moves) rather than
|
||||||
2. Three strengths (bullet points, max 8 words each)
|
generic statements that could apply to any competitor:
|
||||||
3. Three weaknesses (bullet points, max 8 words each)
|
1. One line positioning summary (max 20 words)
|
||||||
4. One pricing observation (max 20 words)
|
2. Three strengths (bullet points, max 14 words each)
|
||||||
|
3. Three weaknesses (bullet points, max 14 words each)
|
||||||
|
4. One pricing observation (max 30 words) — reference the actual price
|
||||||
|
range and/or a real recent price change when one is available
|
||||||
|
|
||||||
Return ONLY valid JSON:
|
Return ONLY valid JSON:
|
||||||
{
|
{
|
||||||
|
|
@ -2415,6 +2448,32 @@ Return ONLY valid JSON:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Word limits were previously 15/8/8/20 — tight enough that the richer
|
||||||
|
input data above (added in the same pass as removing the placeholder
|
||||||
|
strings) was mostly getting compressed away into generic-sounding
|
||||||
|
fragments rather than actually showing up in the output. Loosened once
|
||||||
|
the input data itself became real; still a scannable card, not a full
|
||||||
|
narrative (that's what the per-run `comments` field, §7, already is —
|
||||||
|
the two are intentionally different: `comments` is a one-off comparison
|
||||||
|
for a specific trip match-up, a battlecard is an evergreen standing
|
||||||
|
summary of a competitor overall).
|
||||||
|
|
||||||
|
`BattlecardCard.vue` previously rendered `positioning` and the
|
||||||
|
`strengths`/`weaknesses` pills only — `content_json.pricing_observation`
|
||||||
|
was generated and stored the whole time but never actually displayed
|
||||||
|
anywhere on the card. Now rendered as its own line below the pills.
|
||||||
|
|
||||||
|
`battlecards.generated_at` was `autodate('generated_at', true)` —
|
||||||
|
onCreate only, never onUpdate (migration 001's `autodate()` helper
|
||||||
|
defaults `onUpdate` to `false`). Every regeneration since a
|
||||||
|
battlecard's first creation genuinely updated `content`/`content_json`
|
||||||
|
underneath while the dashboard's "Updated {generated_at}" timestamp
|
||||||
|
stayed frozen at the original creation date — a real battlecard
|
||||||
|
refresh could look untouched for days. Fixed via migration 009
|
||||||
|
(in-place field mutation — `collection.fields.getByName('generated_at').onUpdate = true`
|
||||||
|
— not a destructive remove+re-add, which would have wiped every
|
||||||
|
existing battlecard's real creation timestamp for no benefit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. Semantic Trip Matching
|
## 12. Semantic Trip Matching
|
||||||
|
|
@ -2605,8 +2664,8 @@ their workbook exactly as before.
|
||||||
|
|
||||||
**The PM can rename the imported tab to anything.**
|
**The PM can rename the imported tab to anything.**
|
||||||
The sidebar's tab selector dropdown reads live tab names via
|
The sidebar's tab selector dropdown reads live tab names via
|
||||||
getWorkbookTabs() every time it opens, and detectColumns() maps
|
getWorkbookTabs() every time it opens, and detectFieldRows_() maps
|
||||||
data to columns by header name — never by tab name or column position.
|
data to rows by label — never by tab name or row position.
|
||||||
Rename the tab "Comp Intel", "Peru Research 2026", or leave it as
|
Rename the tab "Comp Intel", "Peru Research 2026", or leave it as
|
||||||
"Bettersight Analysis" — pullLatestResults() writes correctly either
|
"Bettersight Analysis" — pullLatestResults() writes correctly either
|
||||||
way as long as the PM selects the intended tab from the dropdown.
|
way as long as the PM selects the intended tab from the dropdown.
|
||||||
|
|
@ -2637,25 +2696,47 @@ way as long as the PM selects the intended tab from the dropdown.
|
||||||
The template Sheet we host on Google Drive contains a single tab
|
The template Sheet we host on Google Drive contains a single tab
|
||||||
named Bettersight Analysis by default (PM can rename after import).
|
named Bettersight Analysis by default (PM can rename after import).
|
||||||
|
|
||||||
**Column structure:** Row 1 is a bold header row with column names
|
**Row structure — TRANSPOSED, not column-per-field.** Column A holds
|
||||||
matching STANDARD_FIELDS in the exact order the extractor produces.
|
one row per field label (company, link, trip name, pricing fields,
|
||||||
Rows 2+ are empty and will be populated by pullLatestResults() based
|
etc. — the exact list and order live in `appscript/Sync.gs`'s
|
||||||
on header-name column detection.
|
`STANDARD_ROWS`/`NGS_ROWS`, hand-ported from the dashboard's own
|
||||||
|
`frontend/src/config/resultFields.js`). Row 1 is reserved for
|
||||||
|
competitor names, starting at column B — one column per competitor in
|
||||||
|
the most recent run. This matches the dashboard's Copy rows /
|
||||||
|
Download Excel export exactly, both of which were verified against
|
||||||
|
the real legacy client reference sheet ("G Compete App V1.xlsx"). An
|
||||||
|
earlier version of this section (and the `detectColumns()` design it
|
||||||
|
described) specified the opposite layout — column-per-field headers
|
||||||
|
in row 1, one row appended per competitor — which was never actually
|
||||||
|
reconciled against that real reference file; `Sync.gs` was rewritten
|
||||||
|
to match the transposed layout instead of the reverse.
|
||||||
|
|
||||||
|
The template tab does not need to be pre-populated with row labels at
|
||||||
|
all — a blank tab is enough. `detectFieldRows_()` bootstraps column A
|
||||||
|
from `STANDARD_ROWS`/`NGS_ROWS` (setting the 'Field' corner label in
|
||||||
|
A1) the first time it finds no recognisable labels there, so the very
|
||||||
|
first pull self-describes the sheet. Every subsequent pull REPLACES
|
||||||
|
the previously-written competitor columns (clears columns B onward)
|
||||||
|
rather than appending new columns indefinitely — the tab always
|
||||||
|
reflects the single most recent run, same as the dashboard's results
|
||||||
|
view.
|
||||||
|
|
||||||
**Header resilience:**
|
**Header resilience:**
|
||||||
- If the PM renames a column header, detectColumns() still finds it
|
- If the PM renames a row label, detectFieldRows_() still finds it as
|
||||||
as long as the header name is one of the recognised aliases in
|
long as it matches (case/punctuation-insensitive) the row's label —
|
||||||
detectColumns()'s header alias map
|
substituting the PM's real company name into the comments row's
|
||||||
- If the PM inserts new columns, reorders columns, or deletes optional
|
`{company}` token first, since that's what's actually written to
|
||||||
columns, detectColumns() adapts — never hardcoded column positions
|
the cell
|
||||||
- If detectColumns() cannot find a required header, it logs a warning
|
- If the PM reorders rows or deletes optional rows, detectFieldRows_()
|
||||||
to the sidebar and falls back to appending the missing column at the
|
adapts — never hardcoded row positions
|
||||||
end of the existing data range with the standard name
|
- If detectFieldRows_() cannot find ANY recognisable row label at all
|
||||||
|
(a genuinely blank tab), it bootstraps the full row list from
|
||||||
|
scratch — see above
|
||||||
|
|
||||||
**Template hosting:**
|
**Template hosting:**
|
||||||
The template is a single Google Sheets file on Drive at a stable share
|
The template is a single Google Sheets file on Drive at a stable share
|
||||||
URL. The URL is stored as an env var TEMPLATE_SHEET_URL and served via
|
URL. The URL is stored as an env var TEMPLATE_SHEET_URL and served via
|
||||||
GET /account/template. Any updates to the template columns are made
|
GET /account/template. Any updates to the template's row list are made
|
||||||
in-place on the source file — no versioning needed since the PM's
|
in-place on the source file — no versioning needed since the PM's
|
||||||
copy is a snapshot at their moment of import.
|
copy is a snapshot at their moment of import.
|
||||||
|
|
||||||
|
|
@ -2665,7 +2746,7 @@ copy is a snapshot at their moment of import.
|
||||||
appscript/
|
appscript/
|
||||||
Code.gs — onOpen(), install entry point
|
Code.gs — onOpen(), install entry point
|
||||||
Validation.gs — validateLicence() email-only
|
Validation.gs — validateLicence() email-only
|
||||||
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||||
Sidebar.html — tab selector + pull button + last-pull status
|
Sidebar.html — tab selector + pull button + last-pull status
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -2677,17 +2758,21 @@ appscript/
|
||||||
* and writes it to the selected tab of the currently open workbook.
|
* and writes it to the selected tab of the currently open workbook.
|
||||||
* Sheet-initiated only — cannot be triggered by any external server.
|
* Sheet-initiated only — cannot be triggered by any external server.
|
||||||
*
|
*
|
||||||
* Writes results using header-based column detection — never
|
* Writes results in the TRANSPOSED layout (one row per field in
|
||||||
* hardcoded column positions. Resilient to any sheet restructuring.
|
* column A, one column per competitor starting at column B) — matches
|
||||||
|
* the dashboard's Copy rows / Download Excel export exactly. Row
|
||||||
|
* detection is label-based, never hardcoded row positions —
|
||||||
|
* resilient to any sheet restructuring.
|
||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* PM clicks [Pull latest results] in sidebar →
|
* PM clicks [Pull latest results] in sidebar →
|
||||||
* validateLicence() confirms authorisation →
|
* validateLicence() confirms authorisation →
|
||||||
* GET Flask /research/history/latest?email={sessionEmail} →
|
* GET Flask /research/history/latest?email={sessionEmail} →
|
||||||
* stored results returned →
|
* stored results + tab_type returned →
|
||||||
* detectColumns(sheet) builds { field: column } map →
|
* detectFieldRows_(sheet, rows) builds { field: rowNumber } map →
|
||||||
* writeResults(results, columnMap, sheet) writes into selected tab →
|
* writeResultsToSheet_(results, rowMap, rows, sheet) writes into
|
||||||
* sidebar shows "Pulled: Today 9:14am · 5 competitors · Peru"
|
* selected tab, one column per competitor →
|
||||||
|
* sidebar shows "Pulled: Today 9:14am · 5 competitors"
|
||||||
*/
|
*/
|
||||||
function pullLatestResults(tabName) { ... }
|
function pullLatestResults(tabName) { ... }
|
||||||
|
|
||||||
|
|
@ -2702,11 +2787,13 @@ function getWorkbookTabs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects column positions by matching header names in row 1.
|
* Detects each field's row number by matching labels in column A
|
||||||
* Returns { fieldName: columnIndex } map.
|
* (rows 2+, row 1 reserved for competitor names). Returns
|
||||||
* Falls back to STANDARD_FIELDS positions if headers not found.
|
* { fieldKey: rowNumber }. Bootstraps column A from the row list
|
||||||
|
* (and sets the 'Field' corner label in A1) if no labels are found at
|
||||||
|
* all — a blank tab is a valid starting point.
|
||||||
*/
|
*/
|
||||||
function detectColumns(sheet) { ... }
|
function detectFieldRows_(sheet, rows, companyName) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sidebar UI
|
### Sidebar UI
|
||||||
|
|
@ -3598,7 +3685,7 @@ Phase 3 — Dashboard (PRIMARY — build before Apps Script)
|
||||||
Phase 4 — Apps Script (Sheet-initiated pull — simple)
|
Phase 4 — Apps Script (Sheet-initiated pull — simple)
|
||||||
31. Deploy as standalone Apps Script — not container-bound to any workbook
|
31. Deploy as standalone Apps Script — not container-bound to any workbook
|
||||||
32. Validation.gs — validateLicence() email-only
|
32. Validation.gs — validateLicence() email-only
|
||||||
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectColumns()
|
33. Sync.gs — pullLatestResults(), getWorkbookTabs(), detectFieldRows_()
|
||||||
34. Sidebar.html — tab selector + [Pull latest results] + last-pull status
|
34. Sidebar.html — tab selector + [Pull latest results] + last-pull status
|
||||||
35. Sheet template tab — importable into any existing workbook
|
35. Sheet template tab — importable into any existing workbook
|
||||||
36. Flask GET /research/history/latest route (reuses history handler)
|
36. Flask GET /research/history/latest route (reuses history handler)
|
||||||
|
|
|
||||||
|
|
@ -5,56 +5,100 @@
|
||||||
* button), never from the dashboard or Flask, since a stateless
|
* button), never from the dashboard or Flask, since a stateless
|
||||||
* backend has no way to reach into whichever Sheet a PM has open
|
* backend has no way to reach into whichever Sheet a PM has open
|
||||||
* (CLAUDE.md §15).
|
* (CLAUDE.md §15).
|
||||||
|
*
|
||||||
|
* Layout: TRANSPOSED — one row per field (column A), one column per
|
||||||
|
* competitor (starting at column B), row 1 reserved for competitor
|
||||||
|
* names. This matches the dashboard's Copy rows / Download Excel
|
||||||
|
* export exactly (frontend/src/config/resultFields.js), which was
|
||||||
|
* itself verified against the real legacy client reference sheet
|
||||||
|
* ("G Compete App V1.xlsx") — NOT the column-per-field/row-per-
|
||||||
|
* competitor layout CLAUDE.md §15 originally described, which was
|
||||||
|
* never reconciled against that real reference file. ROW_DEFS below
|
||||||
|
* is a hand-ported mirror of resultFields.js's STANDARD_ROWS/NGS_ROWS
|
||||||
|
* — Apps Script runs in a separate sandbox with no module imports, so
|
||||||
|
* this list is duplicated rather than shared. Keep both in sync if
|
||||||
|
* either changes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
|
const DEFAULT_TAB_NAME = 'Bettersight Analysis';
|
||||||
|
|
||||||
// Every STANDARD_FIELDS column's recognised header aliases, written
|
function divOrNull_(a, b) {
|
||||||
// out explicitly rather than guessed at mid-scrape. The first alias
|
if (a == null || b == null || b === 0) return null;
|
||||||
// in each list is the canonical label used when writing a brand-new
|
return a / b;
|
||||||
// header row. Matching is case- and punctuation-insensitive (see
|
}
|
||||||
// normaliseHeader_), so "Rack Rate", "rack rate", and "RACK-RATE" all
|
|
||||||
// match the same entry.
|
|
||||||
const FIELD_ALIASES = {
|
|
||||||
company: ['Company', 'Competitor', 'Competitor Name', 'Operator'],
|
|
||||||
link: ['Link', 'URL', 'Page URL', 'Trip URL', 'Web Link'],
|
|
||||||
tripName: ['Trip Name', 'Trip', 'Product Name', 'Tour Name'],
|
|
||||||
tripCode: ['Trip Code', 'Code', 'Product Code', 'SKU'],
|
|
||||||
serviceLevel: ['Service Level', 'Style', 'Accommodation Level', 'Class'],
|
|
||||||
groupSize: ['Group Size', 'Max Group Size', 'Group', 'Party Size'],
|
|
||||||
duration: ['Duration', 'Days', 'Length', 'Trip Length', 'Number of Days'],
|
|
||||||
meals: ['Meals', 'Included Meals', 'Total Meals', 'Meal Count'],
|
|
||||||
startLocation: ['Start Location', 'Departure City', 'Starts', 'Start City', 'Start Point'],
|
|
||||||
endLocation: ['End Location', 'End City', 'Ends', 'Finish City', 'End Point'],
|
|
||||||
departures: ['Departures', 'Departures Per Year', 'Frequency', 'Number of Departures'],
|
|
||||||
seasonality: ['Seasonality', 'Season', 'Price Bands', 'Seasonal Pricing'],
|
|
||||||
startDays: ['Start Days', 'Departure Days', 'Days of Week'],
|
|
||||||
targetAudience: ['Target Audience', 'Audience', 'Traveller Type', 'Traveler Type'],
|
|
||||||
hotels: ['Hotels', 'Accommodation', 'Hotel Type', 'Lodging'],
|
|
||||||
activities: ['Activities', 'Included Activities', 'Key Activities', 'Highlights'],
|
|
||||||
relevancy: ['Relevancy', 'Relevance', 'Match Score', 'Relevance Score'],
|
|
||||||
majorityPrice: ['Majority Price', 'Standard Price', 'Rack Rate', 'Price', 'Price USD', 'Adult Price'],
|
|
||||||
priceLow: ['Price Low', 'Low Price', 'Min Price', 'Lowest Price', 'Price From'],
|
|
||||||
priceHigh: ['Price High', 'High Price', 'Max Price', 'Highest Price', 'Price To'],
|
|
||||||
comments: ['Comments', 'Analysis', 'Notes', 'Observations'],
|
|
||||||
dateUsed: ['Date Used', 'Departure Date', 'Date', 'Sample Date'],
|
|
||||||
audPrice: ['AUD Price', 'AUD', 'Price AUD', 'Australian Dollar Price'],
|
|
||||||
cadPrice: ['CAD Price', 'CAD', 'Price CAD', 'Canadian Dollar Price'],
|
|
||||||
eurPrice: ['EUR Price', 'EUR', 'Price EUR', 'Euro Price'],
|
|
||||||
gbpPrice: ['GBP Price', 'GBP', 'Price GBP', 'British Pound Price', 'Pound Price']
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback column positions, used only when row 1 has no recognisable
|
function mulOrNull_(a, b) {
|
||||||
// headers at all (e.g. a brand-new blank tab). Ordered left to right
|
if (a == null || b == null) return null;
|
||||||
// starting at column 1 — logs a warning when this path is taken.
|
return a * b;
|
||||||
const STANDARD_FIELDS_ORDER = Object.keys(FIELD_ALIASES);
|
}
|
||||||
|
|
||||||
|
const STANDARD_ROWS = [
|
||||||
|
{ key: 'company', label: 'Company', source: 'name' },
|
||||||
|
{ key: 'link', label: 'Link' },
|
||||||
|
{ key: 'tripName', label: 'Trip Name' },
|
||||||
|
{ key: 'tripCode', label: 'Trip Code' },
|
||||||
|
{ key: 'serviceLevel', label: 'Service Level' },
|
||||||
|
{ key: 'groupSize', label: 'Group size' },
|
||||||
|
{ key: 'duration', label: 'Duration (days)' },
|
||||||
|
{ key: 'meals', label: 'Meals' },
|
||||||
|
{ key: 'startLocation', label: 'Start Location' },
|
||||||
|
{ key: 'endLocation', label: 'End Location' },
|
||||||
|
{ key: 'departures', label: '# Departures Offered per year' },
|
||||||
|
{ key: 'totalInventory', label: 'Total inventory', compute: function (r) { return mulOrNull_(r && r.departures, r && r.groupSize); } },
|
||||||
|
{ key: 'seasonality', label: 'Seasonality (what are the price bands in terms of dates?)' },
|
||||||
|
{ key: 'startDays', label: 'Start days of week' },
|
||||||
|
{ key: 'targetAudience', label: 'Target audience if known' },
|
||||||
|
{ key: 'hotels', label: 'Hotels (if applicable)' },
|
||||||
|
{ key: 'activities', label: 'Included Activities' },
|
||||||
|
{ key: 'relevancy', label: 'Relevancy rating' },
|
||||||
|
{ key: 'majorityPrice', label: 'Majority Price (USD)' },
|
||||||
|
{ key: 'priceLow', label: 'Price low (USD)' },
|
||||||
|
{ key: 'priceHigh', label: 'Price high (USD)' },
|
||||||
|
{ key: 'pricePerDay', label: 'Price per day (USD)', compute: function (r) { return divOrNull_(r && r.majorityPrice, r && r.duration); } },
|
||||||
|
{ key: 'comments', label: 'Suggested changes for {company}/ Competitive Comments' },
|
||||||
|
{ key: 'dateUsed', label: 'Date Used for Majority Price' },
|
||||||
|
{ key: 'audPrice', label: 'AUD Majority price' },
|
||||||
|
{ key: 'cadPrice', label: 'CAD Majority price' },
|
||||||
|
{ key: 'eurPrice', label: 'EUR Majority price' },
|
||||||
|
{ key: 'gbpPrice', label: 'GBP Majority price' },
|
||||||
|
{ key: 'audPricePerDay', label: 'AUD Price/day', compute: function (r) { return divOrNull_(r && r.audPrice, r && r.duration); } },
|
||||||
|
{ key: 'cadPricePerDay', label: 'CAD Price/day', compute: function (r) { return divOrNull_(r && r.cadPrice, r && r.duration); } },
|
||||||
|
{ key: 'eurPricePerDay', label: 'EUR Price/day', compute: function (r) { return divOrNull_(r && r.eurPrice, r && r.duration); } },
|
||||||
|
{ key: 'gbpPricePerDay', label: 'GBP Price/day', compute: function (r) { return divOrNull_(r && r.gbpPrice, r && r.duration); } }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Spliced in after 'activities' — matches resultFields.js's insertAfter().
|
||||||
|
const NGS_EXTRA_ROWS = [
|
||||||
|
{ key: 'exclusiveAccess', label: 'Exclusive Access/Special Unique Inclusions' },
|
||||||
|
{ key: 'groupLeader', label: 'Group Leader and/or Local Expert Description' },
|
||||||
|
{ key: 'sustainability', label: 'Sustainability Inclusions (if applicable)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function getRowsForTabType_(tabType) {
|
||||||
|
if (tabType !== 'NGS') return STANDARD_ROWS;
|
||||||
|
const activitiesIndex = STANDARD_ROWS.findIndex(function (r) { return r.key === 'activities'; });
|
||||||
|
return STANDARD_ROWS.slice(0, activitiesIndex + 1)
|
||||||
|
.concat(NGS_EXTRA_ROWS, STANDARD_ROWS.slice(activitiesIndex + 1));
|
||||||
|
}
|
||||||
|
|
||||||
function normaliseHeader_(text) {
|
function normaliseHeader_(text) {
|
||||||
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
return String(text || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalLabel_(field) {
|
/**
|
||||||
return FIELD_ALIASES[field][0];
|
* Substitutes the '{company}' token in the comments row's label.
|
||||||
|
* Apps Script has no direct route to the tenant's display name (only
|
||||||
|
* the dashboard's Pinia store carries that) — always falls back to
|
||||||
|
* 'your company'. A no-op for every other row (no token present).
|
||||||
|
*/
|
||||||
|
function formatLabel_(label, companyName) {
|
||||||
|
return label.replace('{company}', companyName || 'your company');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawValue_(row, item) {
|
||||||
|
if (row.compute) return row.compute(item.result || {});
|
||||||
|
if (row.source === 'name') return item.name;
|
||||||
|
return item.result ? item.result[row.key] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -69,84 +113,85 @@ function getWorkbookTabs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects the column index for each field by matching header names in
|
* Detects the row index for each field by matching normalised labels
|
||||||
* row 1 of the target sheet against FIELD_ALIASES — never hardcoded
|
* in column A (rows 2+, row 1 is reserved for competitor names)
|
||||||
* column positions. Falls back to STANDARD_FIELDS_ORDER's left-to-right
|
* against `rows` — never hardcoded row positions, so a PM can reorder
|
||||||
* positions only when row 1 has no recognisable headers at all — logs
|
* or delete rows freely. Falls back to `rows`' own order, starting at
|
||||||
* a warning when that fallback is used.
|
* row 2, only when column A has no recognisable labels at all (e.g. a
|
||||||
|
* brand-new blank tab) — writes those labels in on the spot (and sets
|
||||||
|
* the 'Field' corner label in A1) so the sheet becomes self-describing
|
||||||
|
* from that point on. A PM never has to hand-type the ~30 row labels
|
||||||
|
* themselves — leaving the tab blank and pulling once is enough.
|
||||||
*
|
*
|
||||||
* Data flow:
|
* Data flow:
|
||||||
* sheet → read row 1 → normalise each header cell →
|
* sheet + rows + companyName → read column A (rows 2..lastRow) →
|
||||||
* for each field, check every alias in FIELD_ALIASES against every
|
* normalise each cell → for each row def, match against its
|
||||||
* header cell → first match wins → column index →
|
* (company-substituted) label → { fieldKey: rowNumber } map →
|
||||||
* { fieldName: columnIndex } map returned
|
* if nothing matched at all: bootstrap column A from `rows` in
|
||||||
|
* order → same map, now fully populated
|
||||||
*/
|
*/
|
||||||
function detectColumns(sheet) {
|
function detectFieldRows_(sheet, rows, companyName) {
|
||||||
const lastColumn = Math.max(sheet.getLastColumn(), 1);
|
const lastRow = sheet.getLastRow();
|
||||||
const headerRow = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
|
const rowMap = {};
|
||||||
const normalisedHeaders = headerRow.map(normaliseHeader_);
|
|
||||||
|
|
||||||
const columnMap = {};
|
if (lastRow >= 2) {
|
||||||
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
const columnA = sheet.getRange(2, 1, lastRow - 1, 1).getValues().map(function (r) { return r[0]; });
|
||||||
const normalisedAliases = FIELD_ALIASES[field].map(normaliseHeader_);
|
const normalisedColumn = columnA.map(normaliseHeader_);
|
||||||
for (let col = 0; col < normalisedHeaders.length; col++) {
|
|
||||||
if (normalisedHeaders[col] && normalisedAliases.indexOf(normalisedHeaders[col]) !== -1) {
|
rows.forEach(function (row) {
|
||||||
columnMap[field] = col + 1; // 1-indexed columns
|
const target = normaliseHeader_(formatLabel_(row.label, companyName));
|
||||||
|
for (let i = 0; i < normalisedColumn.length; i++) {
|
||||||
|
if (normalisedColumn[i] && normalisedColumn[i] === target) {
|
||||||
|
rowMap[row.key] = i + 2; // +2: back to 1-indexed, offset past row 1
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const foundAnyHeader = Object.keys(columnMap).length > 0;
|
const foundAny = Object.keys(rowMap).length > 0;
|
||||||
if (!foundAnyHeader) {
|
if (!foundAny) {
|
||||||
Logger.log('detectColumns: no recognisable headers in row 1 — falling back to STANDARD_FIELDS_ORDER positions');
|
Logger.log('detectFieldRows_: no recognisable row labels in column A — bootstrapping a fresh template');
|
||||||
STANDARD_FIELDS_ORDER.forEach(function (field, i) {
|
sheet.getRange(1, 1).setValue('Field');
|
||||||
columnMap[field] = i + 1;
|
rows.forEach(function (row, i) {
|
||||||
|
const rowNumber = i + 2;
|
||||||
|
sheet.getRange(rowNumber, 1).setValue(formatLabel_(row.label, companyName));
|
||||||
|
rowMap[row.key] = rowNumber;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return columnMap;
|
return rowMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes the header row (if the sheet is empty) and appends one row
|
* Writes one column per competitor into the sheet, using rowMap from
|
||||||
* per competitor result, using columnMap from detectColumns(). Only
|
* detectFieldRows_(). Only writes fields present in rowMap — a row the
|
||||||
* writes fields present in columnMap.
|
* PM deleted from the template is silently skipped, same resilience
|
||||||
|
* rule the old column-based model used.
|
||||||
|
*
|
||||||
|
* Every pull REPLACES the previously-written competitor columns
|
||||||
|
* (clears columns B onward first) rather than appending indefinitely
|
||||||
|
* — this tab always reflects the single most recent run, same as the
|
||||||
|
* dashboard's results view. Column A's row labels are left untouched.
|
||||||
*/
|
*/
|
||||||
function writeResultsToSheet_(results, columnMap, sheet) {
|
function writeResultsToSheet_(results, rowMap, rows, sheet) {
|
||||||
if (sheet.getLastRow() === 0) {
|
|
||||||
const headerCells = [];
|
|
||||||
Object.keys(columnMap).forEach(function (field) {
|
|
||||||
headerCells[columnMap[field] - 1] = canonicalLabel_(field);
|
|
||||||
});
|
|
||||||
sheet.getRange(1, 1, 1, headerCells.length).setValues([headerCells.map(function (h) { return h || ''; })]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextRow = sheet.getLastRow() + 1;
|
|
||||||
const successItems = (results && results.success) || [];
|
const successItems = (results && results.success) || [];
|
||||||
|
|
||||||
successItems.forEach(function (item) {
|
const lastColumn = sheet.getLastColumn();
|
||||||
const extracted = item.result || {};
|
if (lastColumn > 1) {
|
||||||
const rowValues = {};
|
sheet.getRange(1, 2, sheet.getMaxRows(), lastColumn - 1).clearContent();
|
||||||
rowValues.company = item.name;
|
|
||||||
rowValues.link = extracted.link || (item.product && item.product.url) || '';
|
|
||||||
|
|
||||||
Object.keys(FIELD_ALIASES).forEach(function (field) {
|
|
||||||
if (field === 'company' || field === 'link') return;
|
|
||||||
const value = extracted[field];
|
|
||||||
if (value !== undefined && value !== null) {
|
|
||||||
rowValues[field] = value;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(rowValues).forEach(function (field) {
|
successItems.forEach(function (item, index) {
|
||||||
const col = columnMap[field];
|
const col = index + 2; // column A is labels, competitors start at column B
|
||||||
if (col) {
|
sheet.getRange(1, col).setValue(item.name);
|
||||||
sheet.getRange(nextRow, col).setValue(rowValues[field]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextRow += 1;
|
rows.forEach(function (row) {
|
||||||
|
const rowNumber = rowMap[row.key];
|
||||||
|
if (!rowNumber) return;
|
||||||
|
const value = rawValue_(row, item);
|
||||||
|
sheet.getRange(rowNumber, col).setValue(value == null ? '' : value);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return successItems.length;
|
return successItems.length;
|
||||||
|
|
@ -160,8 +205,9 @@ function writeResultsToSheet_(results, columnMap, sheet) {
|
||||||
* Data flow:
|
* Data flow:
|
||||||
* validateLicence() → confirms authorisation →
|
* validateLicence() → confirms authorisation →
|
||||||
* GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... →
|
* GET {BETTERSIGHT_API_BASE_URL}/research/history/latest?email=... →
|
||||||
* stored results returned → target tab resolved (creates it if
|
* stored results + tab_type returned → target tab resolved (creates
|
||||||
* missing) → detectColumns(sheet) → writeResultsToSheet_() →
|
* it if missing) → getRowsForTabType_(tab_type) selects the
|
||||||
|
* Standard/NGS row list → detectFieldRows_() → writeResultsToSheet_() →
|
||||||
* last-pull status saved to UserProperties, sync_from_sheet
|
* last-pull status saved to UserProperties, sync_from_sheet
|
||||||
* onboarding flag flipped on Flask on first success →
|
* onboarding flag flipped on Flask on first success →
|
||||||
* { success, rowsWritten, summary } returned to the sidebar
|
* { success, rowsWritten, summary } returned to the sidebar
|
||||||
|
|
@ -202,8 +248,9 @@ function pullLatestResults(tabName) {
|
||||||
sheet = spreadsheet.insertSheet(targetTabName);
|
sheet = spreadsheet.insertSheet(targetTabName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnMap = detectColumns(sheet);
|
const rows = getRowsForTabType_(run.tab_type);
|
||||||
const rowsWritten = writeResultsToSheet_(run.results, columnMap, sheet);
|
const rowMap = detectFieldRows_(sheet, rows, null);
|
||||||
|
const rowsWritten = writeResultsToSheet_(run.results, rowMap, rows, sheet);
|
||||||
|
|
||||||
const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`;
|
const summary = `${rowsWritten} competitor${rowsWritten === 1 ? '' : 's'}`;
|
||||||
const timestamp = new Date().toLocaleString();
|
const timestamp = new Date().toLocaleString();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@
|
||||||
* frontend bundle. Real per-PM authorization is the email check inside
|
* frontend bundle. Real per-PM authorization is the email check inside
|
||||||
* validateLicence() below, not this header.
|
* validateLicence() below, not this header.
|
||||||
*
|
*
|
||||||
* Replace these two values before publishing the add-on.
|
* TEMPORARY — pointed at a local dev cloudflared quick tunnel for
|
||||||
|
* testing the pull flow end-to-end before a real production domain
|
||||||
|
* exists. Quick tunnels are not stable: the hostname changes every
|
||||||
|
* time cloudflared restarts, and Cloudflare gives no uptime guarantee
|
||||||
|
* on the free, account-less tier. Replace both values with the real
|
||||||
|
* production API base URL + API_SECRET_KEY before publishing the
|
||||||
|
* add-on for real PM use.
|
||||||
*/
|
*/
|
||||||
const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io';
|
const BETTERSIGHT_API_BASE_URL = 'https://api.bettersight.io';
|
||||||
const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY';
|
const BETTERSIGHT_API_KEY = 'REPLACE_WITH_PRODUCTION_API_KEY';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
//
|
||||||
|
// Fixes battlecards.generated_at to also stamp on UPDATE, not just
|
||||||
|
// CREATE. 001_initial_schema.js's autodate('generated_at', true) set
|
||||||
|
// onCreate=true, onUpdate=false (the helper's default) — meaning every
|
||||||
|
// subsequent battlecard_service.generate_battlecard() regeneration (an
|
||||||
|
// upsert -> PATCH on the existing record, per
|
||||||
|
// battlecard_repository.upsert()) left generated_at frozen at the
|
||||||
|
// battlecard's FIRST-ever creation date, even though content/
|
||||||
|
// content_json genuinely change underneath. BattlecardCard.vue
|
||||||
|
// prominently shows "Updated {generated_at}", so a regenerated
|
||||||
|
// battlecard with real fresh content looked like it hadn't been
|
||||||
|
// touched in days — confirmed directly: a manual generate_battlecard()
|
||||||
|
// call updated content/content_json but generated_at stayed at its
|
||||||
|
// original timestamp from days earlier.
|
||||||
|
//
|
||||||
|
// In-place field mutation (getByName + reassign) rather than
|
||||||
|
// remove+re-add — removing an existing field drops its underlying
|
||||||
|
// column (destructive), which would wipe every existing battlecard's
|
||||||
|
// real creation timestamp for no benefit; this only changes the
|
||||||
|
// field's future stamping behavior.
|
||||||
|
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId('battlecards');
|
||||||
|
const field = collection.fields.getByName('generated_at');
|
||||||
|
field.onUpdate = true;
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId('battlecards');
|
||||||
|
const field = collection.fields.getByName('generated_at');
|
||||||
|
field.onUpdate = false;
|
||||||
|
return app.save(collection);
|
||||||
|
});
|
||||||
|
|
@ -1,25 +1,45 @@
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from repositories.product_repository import product_repository
|
from repositories.product_repository import product_repository
|
||||||
from repositories.competitor_repository import competitor_repository
|
from repositories.competitor_repository import competitor_repository
|
||||||
from repositories.battlecard_repository import battlecard_repository
|
from repositories.battlecard_repository import battlecard_repository
|
||||||
|
from repositories.price_history_repository import price_history_repository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# How many of a competitor's most-recently-scraped active products feed
|
||||||
|
# a battlecard. Previously 10 — skewed the summary toward whatever
|
||||||
|
# happened to be scraped most recently (e.g. this week's watchlist
|
||||||
|
# picks) instead of a reasonably complete view of the tracked catalogue.
|
||||||
|
# Adjustable here only, same convention as core/scraper.py's
|
||||||
|
# CACHE_STALENESS_HOURS.
|
||||||
|
BATTLECARD_PRODUCT_LIMIT = 50
|
||||||
|
|
||||||
|
# How far back price_history is considered "recent" for the battlecard's
|
||||||
|
# recent-changes line.
|
||||||
|
RECENT_CHANGES_WINDOW_DAYS = 30
|
||||||
|
|
||||||
BATTLECARD_PROMPT = '''You are a competitive intelligence analyst for an adventure travel operator.
|
BATTLECARD_PROMPT = '''You are a competitive intelligence analyst for an adventure travel operator.
|
||||||
Based on this competitor data, write a concise battlecard.
|
Based on this competitor data, write a concise battlecard.
|
||||||
|
|
||||||
Competitor: {name}
|
Competitor: {name}
|
||||||
Top trips: {top_trips}
|
Top trips: {top_trips}
|
||||||
|
Number of tracked trips: {trip_count}
|
||||||
Price range: {price_low} - {price_high} USD
|
Price range: {price_low} - {price_high} USD
|
||||||
Avg duration: {avg_duration} days
|
Avg duration: {avg_duration} days
|
||||||
Positioning: {positioning_summary}
|
Service levels offered: {service_levels}
|
||||||
Recent changes: {recent_changes}
|
Typical target audience: {target_audiences}
|
||||||
|
Common activities/themes: {activities_summary}
|
||||||
|
Recent price changes (last 30 days): {recent_changes}
|
||||||
|
|
||||||
Write:
|
Write — ground every point in the specific data above (real prices,
|
||||||
1. One line positioning summary (max 15 words)
|
service levels, activities, audience, recent price moves) rather than
|
||||||
2. Three strengths (bullet points, max 8 words each)
|
generic statements that could apply to any competitor:
|
||||||
3. Three weaknesses (bullet points, max 8 words each)
|
1. One line positioning summary (max 20 words)
|
||||||
4. One pricing observation (max 20 words)
|
2. Three strengths (bullet points, max 14 words each)
|
||||||
|
3. Three weaknesses (bullet points, max 14 words each)
|
||||||
|
4. One pricing observation (max 30 words) — reference the actual price
|
||||||
|
range and/or a real recent price change when one is available
|
||||||
|
|
||||||
Return ONLY valid JSON:
|
Return ONLY valid JSON:
|
||||||
{{
|
{{
|
||||||
|
|
@ -30,28 +50,106 @@ Return ONLY valid JSON:
|
||||||
}}'''
|
}}'''
|
||||||
|
|
||||||
|
|
||||||
|
def _top_values(values, limit=3):
|
||||||
|
"""
|
||||||
|
Dedupes a list of possibly-null strings, preserving first-seen
|
||||||
|
order, and joins the top `limit` into one comma-separated string.
|
||||||
|
Used to summarise service_level/target_audience/activities across a
|
||||||
|
competitor's tracked products without just picking the single most
|
||||||
|
recent one.
|
||||||
|
"""
|
||||||
|
seen = []
|
||||||
|
for value in values:
|
||||||
|
if value and value not in seen:
|
||||||
|
seen.append(value)
|
||||||
|
return ', '.join(seen[:limit])
|
||||||
|
|
||||||
|
|
||||||
def _summarise_products(products):
|
def _summarise_products(products):
|
||||||
"""Reduces a list of product records into the plain values BATTLECARD_PROMPT needs."""
|
"""
|
||||||
top_trips = ', '.join(p.get('trip_name', '') for p in products[:5] if p.get('trip_name'))
|
Reduces a competitor's tracked products into the plain values
|
||||||
|
BATTLECARD_PROMPT needs. Previously only derived 3 numbers
|
||||||
|
(price range, avg duration) plus a joined trip-name list — ignored
|
||||||
|
service_level/target_audience/activities even though every products
|
||||||
|
row already has them (CLAUDE.md §12).
|
||||||
|
"""
|
||||||
|
top_trips = ', '.join(p.get('trip_name', '') for p in products[:8] if p.get('trip_name'))
|
||||||
prices = [p['majority_price_usd'] for p in products if p.get('majority_price_usd')]
|
prices = [p['majority_price_usd'] for p in products if p.get('majority_price_usd')]
|
||||||
durations = [p['duration_days'] for p in products if p.get('duration_days')]
|
durations = [p['duration_days'] for p in products if p.get('duration_days')]
|
||||||
return {
|
return {
|
||||||
'top_trips': top_trips or 'No products on record',
|
'top_trips': top_trips or 'No products on record',
|
||||||
|
'trip_count': len(products),
|
||||||
'price_low': min(prices) if prices else 'unknown',
|
'price_low': min(prices) if prices else 'unknown',
|
||||||
'price_high': max(prices) if prices else 'unknown',
|
'price_high': max(prices) if prices else 'unknown',
|
||||||
'avg_duration': round(sum(durations) / len(durations)) if durations else 'unknown',
|
'avg_duration': round(sum(durations) / len(durations)) if durations else 'unknown',
|
||||||
|
'service_levels': _top_values([p.get('service_level') for p in products]) or 'unknown',
|
||||||
|
'target_audiences': _top_values([p.get('target_audience') for p in products]) or 'unknown',
|
||||||
|
'activities_summary': _top_values([p.get('activities') for p in products], limit=5) or 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _summarise_recent_price_changes(products, days=RECENT_CHANGES_WINDOW_DAYS):
|
||||||
|
"""
|
||||||
|
Replaces the old hardcoded 'See price_history for this competitor.'
|
||||||
|
placeholder with a real summary of that competitor's actual recent
|
||||||
|
price moves — the model was previously being told to reference data
|
||||||
|
it was never actually given.
|
||||||
|
|
||||||
|
Every price_history row already represents a genuine change
|
||||||
|
(analysis_service._save_scrape_result() only writes one when
|
||||||
|
old_price != new_price), so no extra magnitude filter is needed here
|
||||||
|
— just a recency window. price_history has no competitor_id column
|
||||||
|
(only product_id/tenant_id per CLAUDE.md §12), so this reuses the
|
||||||
|
existing price_history_repository.list_for_product() per already-
|
||||||
|
fetched product rather than adding a new repository method.
|
||||||
|
|
||||||
|
Data flow:
|
||||||
|
products → per product: price_history_repository.list_for_product(limit=3) →
|
||||||
|
entries within `days` with a real change_percent →
|
||||||
|
sorted by |change_percent| descending (most notable first) →
|
||||||
|
top 5 joined into one line, or a "no changes" fallback if none qualify
|
||||||
|
"""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
changes = []
|
||||||
|
for product in products:
|
||||||
|
entries = price_history_repository.list_for_product(product['id'], limit=3)
|
||||||
|
for entry in entries:
|
||||||
|
scraped_at = entry.get('scraped_at')
|
||||||
|
change_percent = entry.get('change_percent')
|
||||||
|
if not scraped_at or change_percent is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
scraped_dt = datetime.fromisoformat(scraped_at.replace('Z', '+00:00'))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
continue
|
||||||
|
if scraped_dt < cutoff:
|
||||||
|
continue
|
||||||
|
direction = 'dropped' if change_percent < 0 else 'rose'
|
||||||
|
price = entry.get('majority_price_usd')
|
||||||
|
price_part = f' to ${price}' if price is not None else ''
|
||||||
|
changes.append((
|
||||||
|
abs(change_percent),
|
||||||
|
f"{product.get('trip_name') or 'a trip'} {direction} {abs(change_percent)}%{price_part}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
return f'No significant price changes in the last {days} days.'
|
||||||
|
|
||||||
|
changes.sort(key=lambda c: c[0], reverse=True)
|
||||||
|
return '; '.join(text for _, text in changes[:5])
|
||||||
|
|
||||||
|
|
||||||
def generate_battlecard(competitor_id, tenant_id):
|
def generate_battlecard(competitor_id, tenant_id):
|
||||||
"""
|
"""
|
||||||
Pulls latest product data for a competitor from PocketBase, passes
|
Pulls a competitor's tracked products (and their real recent price
|
||||||
structured data to the OpenRouter extraction model, and saves the
|
history) from PocketBase, passes a real structured summary to the
|
||||||
generated battlecard text/JSON back to PocketBase.
|
OpenRouter extraction model, and saves the generated battlecard
|
||||||
|
text/JSON back to PocketBase.
|
||||||
|
|
||||||
Data flow:
|
Data flow:
|
||||||
competitor_id → PocketBase products (latest 10) →
|
competitor_id → PocketBase products (latest BATTLECARD_PRODUCT_LIMIT) →
|
||||||
structured prompt → OpenRouter (claude-haiku via core.extractor) →
|
_summarise_products() + _summarise_recent_price_changes() →
|
||||||
|
structured prompt → OpenRouter (via core.extractor) →
|
||||||
battlecard text + JSON → PocketBase battlecards table →
|
battlecard text + JSON → PocketBase battlecards table →
|
||||||
battlecard dict returned for immediate use
|
battlecard dict returned for immediate use
|
||||||
"""
|
"""
|
||||||
|
|
@ -61,17 +159,21 @@ def generate_battlecard(competitor_id, tenant_id):
|
||||||
from core.extractor import extract_with_openrouter
|
from core.extractor import extract_with_openrouter
|
||||||
|
|
||||||
competitor = competitor_repository.get_by_id(competitor_id)
|
competitor = competitor_repository.get_by_id(competitor_id)
|
||||||
products = product_repository.get_latest_for_competitor(competitor_id, limit=10)
|
products = product_repository.get_latest_for_competitor(competitor_id, limit=BATTLECARD_PRODUCT_LIMIT)
|
||||||
summary = _summarise_products(products)
|
summary = _summarise_products(products)
|
||||||
|
recent_changes = _summarise_recent_price_changes(products)
|
||||||
|
|
||||||
prompt = BATTLECARD_PROMPT.format(
|
prompt = BATTLECARD_PROMPT.format(
|
||||||
name=competitor.get('name', 'Unknown') if competitor else 'Unknown',
|
name=competitor.get('name', 'Unknown') if competitor else 'Unknown',
|
||||||
top_trips=summary['top_trips'],
|
top_trips=summary['top_trips'],
|
||||||
|
trip_count=summary['trip_count'],
|
||||||
price_low=summary['price_low'],
|
price_low=summary['price_low'],
|
||||||
price_high=summary['price_high'],
|
price_high=summary['price_high'],
|
||||||
avg_duration=summary['avg_duration'],
|
avg_duration=summary['avg_duration'],
|
||||||
positioning_summary='Derived from current product catalogue.',
|
service_levels=summary['service_levels'],
|
||||||
recent_changes='See price_history for this competitor.',
|
target_audiences=summary['target_audiences'],
|
||||||
|
activities_summary=summary['activities_summary'],
|
||||||
|
recent_changes=recent_changes,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import importlib
|
import importlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from services import battlecard_service
|
from services import battlecard_service
|
||||||
from repositories.tenant_repository import tenant_repository
|
from repositories.tenant_repository import tenant_repository
|
||||||
from repositories.competitor_repository import competitor_repository
|
from repositories.competitor_repository import competitor_repository
|
||||||
from repositories.product_repository import product_repository
|
from repositories.product_repository import product_repository
|
||||||
from repositories.battlecard_repository import battlecard_repository
|
from repositories.battlecard_repository import battlecard_repository
|
||||||
|
from repositories.price_history_repository import price_history_repository
|
||||||
|
|
||||||
|
|
||||||
def _install_fake_extractor(monkeypatch, fn):
|
def _install_fake_extractor(monkeypatch, fn):
|
||||||
|
|
@ -74,3 +76,132 @@ def test_generate_battlecard_handles_no_products_gracefully(monkeypatch):
|
||||||
|
|
||||||
card = battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
|
card = battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
|
||||||
assert card is not None
|
assert card is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _top_values ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_top_values_dedupes_and_preserves_first_seen_order():
|
||||||
|
result = battlecard_service._top_values(['Mid-range', 'Budget', 'Mid-range', 'Premium', 'Budget'])
|
||||||
|
assert result == 'Mid-range, Budget, Premium'
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_values_skips_null_and_empty_entries():
|
||||||
|
result = battlecard_service._top_values([None, 'Families', '', None, 'Couples'])
|
||||||
|
assert result == 'Families, Couples'
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_values_respects_limit():
|
||||||
|
result = battlecard_service._top_values(['A', 'B', 'C', 'D'], limit=2)
|
||||||
|
assert result == 'A, B'
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_values_empty_input_returns_empty_string():
|
||||||
|
assert battlecard_service._top_values([]) == ''
|
||||||
|
|
||||||
|
|
||||||
|
# ── _summarise_products (richer fields) ────────────────────────────
|
||||||
|
|
||||||
|
def test_summarise_products_includes_trip_count_and_new_fields():
|
||||||
|
products = [
|
||||||
|
{'trip_name': 'Peru Classic', 'majority_price_usd': 2190, 'duration_days': 15,
|
||||||
|
'service_level': 'Mid-range', 'target_audience': 'Couples', 'activities': 'Trekking'},
|
||||||
|
{'trip_name': 'Peru Premium', 'majority_price_usd': 3200, 'duration_days': 15,
|
||||||
|
'service_level': 'Premium', 'target_audience': 'Couples', 'activities': 'Trekking, Rafting'},
|
||||||
|
]
|
||||||
|
summary = battlecard_service._summarise_products(products)
|
||||||
|
|
||||||
|
assert summary['trip_count'] == 2
|
||||||
|
assert summary['service_levels'] == 'Mid-range, Premium'
|
||||||
|
assert summary['target_audiences'] == 'Couples'
|
||||||
|
assert 'Trekking' in summary['activities_summary']
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_products_new_fields_default_to_unknown_when_missing():
|
||||||
|
products = [{'trip_name': 'Peru Classic'}]
|
||||||
|
summary = battlecard_service._summarise_products(products)
|
||||||
|
|
||||||
|
assert summary['service_levels'] == 'unknown'
|
||||||
|
assert summary['target_audiences'] == 'unknown'
|
||||||
|
assert summary['activities_summary'] == 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
# ── _summarise_recent_price_changes ────────────────────────────────
|
||||||
|
|
||||||
|
def _iso_days_ago(days):
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_recent_price_changes_includes_change_within_window():
|
||||||
|
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||||
|
price_history_repository.create({
|
||||||
|
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||||
|
'change_percent': -12.0, 'scraped_at': _iso_days_ago(5),
|
||||||
|
})
|
||||||
|
|
||||||
|
result = battlecard_service._summarise_recent_price_changes([product])
|
||||||
|
|
||||||
|
assert 'Peru Classic dropped 12.0%' in result
|
||||||
|
assert '$1990' in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_recent_price_changes_ignores_entries_outside_window():
|
||||||
|
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||||
|
price_history_repository.create({
|
||||||
|
'tenant_id': 't1', 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||||
|
'change_percent': -12.0, 'scraped_at': _iso_days_ago(45),
|
||||||
|
})
|
||||||
|
|
||||||
|
result = battlecard_service._summarise_recent_price_changes([product])
|
||||||
|
|
||||||
|
assert result == 'No significant price changes in the last 30 days.'
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_recent_price_changes_sorts_by_magnitude_descending():
|
||||||
|
product_a = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Small Move'})
|
||||||
|
product_b = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Big Move'})
|
||||||
|
price_history_repository.create({
|
||||||
|
'tenant_id': 't1', 'product_id': product_a['id'], 'majority_price_usd': 1000,
|
||||||
|
'change_percent': 2.0, 'scraped_at': _iso_days_ago(1),
|
||||||
|
})
|
||||||
|
price_history_repository.create({
|
||||||
|
'tenant_id': 't1', 'product_id': product_b['id'], 'majority_price_usd': 2000,
|
||||||
|
'change_percent': -25.0, 'scraped_at': _iso_days_ago(2),
|
||||||
|
})
|
||||||
|
|
||||||
|
result = battlecard_service._summarise_recent_price_changes([product_a, product_b])
|
||||||
|
|
||||||
|
assert result.index('Big Move') < result.index('Small Move')
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarise_recent_price_changes_falls_back_when_no_price_history_at_all():
|
||||||
|
product = product_repository.create({'tenant_id': 't1', 'competitor_id': 'c1', 'trip_name': 'Peru Classic'})
|
||||||
|
|
||||||
|
result = battlecard_service._summarise_recent_price_changes([product])
|
||||||
|
|
||||||
|
assert result == 'No significant price changes in the last 30 days.'
|
||||||
|
|
||||||
|
|
||||||
|
# ── generate_battlecard() end-to-end prompt content ────────────────
|
||||||
|
|
||||||
|
def test_generate_battlecard_prompt_uses_real_data_not_placeholders(monkeypatch):
|
||||||
|
tenant, competitor = _setup()
|
||||||
|
product = product_repository.get_latest_for_competitor(competitor['id'])[0]
|
||||||
|
product_repository.update(product['id'], {'service_level': 'Premium', 'target_audience': 'Couples'})
|
||||||
|
price_history_repository.create({
|
||||||
|
'tenant_id': tenant['id'], 'product_id': product['id'], 'majority_price_usd': 1990,
|
||||||
|
'change_percent': -9.1, 'scraped_at': _iso_days_ago(2),
|
||||||
|
})
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
_install_fake_extractor(monkeypatch, lambda prompt, content: captured.update(prompt=prompt) or {
|
||||||
|
'positioning': 'x', 'strengths': [], 'weaknesses': [], 'pricing_observation': 'x',
|
||||||
|
})
|
||||||
|
|
||||||
|
battlecard_service.generate_battlecard(competitor['id'], tenant['id'])
|
||||||
|
|
||||||
|
assert 'Premium' in captured['prompt']
|
||||||
|
assert 'Couples' in captured['prompt']
|
||||||
|
assert 'dropped 9.1%' in captured['prompt']
|
||||||
|
assert 'See price_history for this competitor.' not in captured['prompt']
|
||||||
|
assert 'Derived from current product catalogue.' not in captured['prompt']
|
||||||
|
assert 'Positioning:' not in captured['prompt'] # positioning_summary input field removed entirely
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,6 +12,7 @@
|
||||||
"@sentry/vue": "^7.120.3",
|
"@sentry/vue": "^7.120.3",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"papaparse": "^5.4.1",
|
"papaparse": "^5.4.1",
|
||||||
"pinia": "^2.2.6",
|
"pinia": "^2.2.6",
|
||||||
"pocketbase": "^0.25.1",
|
"pocketbase": "^0.25.1",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,53 @@ async function copyRows() {
|
||||||
// real pullLatestResults() success from inside the Sheet itself.
|
// real pullLatestResults() success from inside the Sheet itself.
|
||||||
setTimeout(() => { copied.value = false }, 2000)
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const downloading = ref(false)
|
||||||
|
|
||||||
|
// Same transposed shape and same raw (unformatted) values as copyRows()
|
||||||
|
// — one row per field, one column per competitor — so the two export
|
||||||
|
// paths never diverge. ExcelJS (not the more common SheetJS/`xlsx`
|
||||||
|
// package) specifically to avoid a known, currently-unpatched high-
|
||||||
|
// severity prototype-pollution/ReDoS advisory in `xlsx` with no fix
|
||||||
|
// available (checked via `npm audit` before choosing).
|
||||||
|
//
|
||||||
|
// Dynamically imported — a static top-level import bundled all of
|
||||||
|
// ExcelJS (~940KB) into AnalysePage's main chunk, loaded on every visit
|
||||||
|
// to the page whether or not the PM ever clicks this button. This way
|
||||||
|
// it's only fetched the first time it's actually needed.
|
||||||
|
async function downloadExcel() {
|
||||||
|
downloading.value = true
|
||||||
|
try {
|
||||||
|
const { default: ExcelJS } = await import('exceljs')
|
||||||
|
const rows = getRowsForTabType(props.tabType)
|
||||||
|
const workbook = new ExcelJS.Workbook()
|
||||||
|
const worksheet = workbook.addWorksheet('Bettersight Analysis')
|
||||||
|
|
||||||
|
worksheet.addRow(['Field', ...props.results.map((item) => item.name)])
|
||||||
|
rows.forEach((row) => {
|
||||||
|
worksheet.addRow([
|
||||||
|
formatLabel(row.label, props.companyName),
|
||||||
|
...props.results.map((item) => rawValue(row, item) ?? ''),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
worksheet.getRow(1).font = { bold: true }
|
||||||
|
worksheet.columns.forEach((col) => { col.width = 24 })
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer()
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `bettersight-analysis-${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -49,6 +96,9 @@ async function copyRows() {
|
||||||
<UButton color="primary" variant="outline" icon="i-lucide-clipboard-copy" @click="copyRows">
|
<UButton color="primary" variant="outline" icon="i-lucide-clipboard-copy" @click="copyRows">
|
||||||
{{ copied ? 'Copied ✓' : 'Copy rows' }}
|
{{ copied ? 'Copied ✓' : 'Copy rows' }}
|
||||||
</UButton>
|
</UButton>
|
||||||
|
<UButton color="neutral" variant="outline" icon="i-lucide-download" :loading="downloading" @click="downloadExcel">
|
||||||
|
Download Excel
|
||||||
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:12px;color:var(--t3);margin:0;line-height:1.5;">
|
<p style="font-size:12px;color:var(--t3);margin:0;line-height:1.5;">
|
||||||
Your results are saved. To pull them directly into your Sheet: open your
|
Your results are saved. To pull them directly into your Sheet: open your
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,16 @@ function timeAgo(ts) {
|
||||||
class="pill danger"
|
class="pill danger"
|
||||||
>{{ weakness }}</span>
|
>{{ weakness }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="battlecard.content_json?.pricing_observation" class="bc-pricing">
|
||||||
|
💰 {{ battlecard.content_json.pricing_observation }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bc-pricing {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--t2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Bettersight — Tier Feature List
|
||||||
|
|
||||||
|
## Analyse — $349/month (MVP tier)
|
||||||
|
|
||||||
|
- **Dashboard** — primary interface for running analysis and viewing results (no Sheet-side analysis UI)
|
||||||
|
- **Trip-intent matching** — PM describes destination/duration/style, Firecrawl `/map` finds competitor trip URLs on demand, with a confirm/swap screen before any scrape runs
|
||||||
|
- **On-demand competitor analysis** — side-by-side dashboard results, with fast path (serve from PocketBase) vs refresh path (live scrape) depending on freshness
|
||||||
|
- **Pull results into Google Sheets** — standalone Apps Script sidebar (installed once, works in any workbook the PM opens): click "Pull latest results" inside the Sheet itself to fetch the PM's most recent completed analysis from Flask and write it into the selected tab, with header-based column detection so it survives the PM renaming/reordering columns; Sheet-initiated, since a stateless backend has no way to reach into whichever Sheet is currently open — a distinct feature from the "Copy rows" clipboard fallback
|
||||||
|
- **Per-seat trip watchlist** — each seat can pin 1 specific trip (from a completed analysis) to their own personal watchlist slot; the nightly cron re-checks exactly those pinned trips instead of scraping every competitor's homepage, so nightly volume is small and predictable (seats × 1) and every resulting alert is about a trip someone explicitly chose to track, not homepage noise
|
||||||
|
- **Auto-generated battlecards** — regenerated after each scrape that produces genuinely fresh data
|
||||||
|
- **In-dashboard notifications** — activity feed for price/product changes (including watchlist-driven nightly checks), plus the weekly Monday HTML email brief via Resend
|
||||||
|
|
||||||
|
Not in MVP: Discover/Intelligence/Enterprise tier features (scheduled catalogue crawl, OSINT signal pipeline, custom fields, Slack/Teams/WhatsApp). Comparable trip discovery (semantic similarity) is a Phase 2 add-on *within* Analyse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Discover — $499/month
|
||||||
|
|
||||||
|
Everything in Analyse, plus proactive surveillance:
|
||||||
|
|
||||||
|
- **Bigger per-seat watchlist** — cap raised from 1 to 2 pinned trips per seat, layered on top of (not instead of) auto-discovery below
|
||||||
|
- **Full catalogue auto-discovery** — Firecrawl `/map` runs weekly per competitor (not just on-demand), Sunday night via n8n — finds trip pages a PM never knew to look for or pin, closing the gap the watchlist alone can't (you can only watch a trip you've already found)
|
||||||
|
- **New trip detection** — flags competitor products added since the last crawl, auto-queued for scraping without PM involvement
|
||||||
|
- **Catalogue completeness** — trip counts per competitor per destination
|
||||||
|
- **Destination gap analysis** — destinations competitors cover that the client doesn't
|
||||||
|
- **Monday brief upgrade** — now includes all auto-discovered new products, not just watchlisted ones
|
||||||
|
- **External notifications** — PM-connected Slack webhook, Microsoft Teams webhook, WhatsApp via Twilio
|
||||||
|
|
||||||
|
Pitch: *"Tell me everything that changed this week that I didn't know to ask about"* — system runs autonomously, catches unknown unknowns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Intelligence — $999/month
|
||||||
|
|
||||||
|
Everything in Discover, plus predictive signals:
|
||||||
|
|
||||||
|
- **Largest per-seat watchlist** — cap raised to 3 pinned trips per seat, on top of full auto-discovery (same as Discover) — the watchlist becomes "pin your top priorities to double-check nightly," not the primary discovery mechanism
|
||||||
|
- **Full OSINT signal pipeline** — 15+ sources (job postings, headcount, funding, SEO/PPC, domain registrations, reviews, news, app store updates, etc.)
|
||||||
|
- **Competitor Trajectory Score** — weighted weekly signal aggregation, mapped to expansion/holding/contracting labels
|
||||||
|
- **Wayback Machine positioning history** — tracks homepage/pricing messaging changes over months via the CDX API
|
||||||
|
- **Network mapping** — leadership, board, and investor connections; cross-competitor overlap detection
|
||||||
|
- **Monthly narrative report** — AI-generated predicted moves with confidence ratings
|
||||||
|
- **Early warning alerts** — Gotify-fired on significant signal clusters
|
||||||
|
|
||||||
|
Pitch: *"Tell me what my competitors are about to do"* — 3–6 month predictive forecasting, not just detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enterprise — $1,499/month (unlisted)
|
||||||
|
|
||||||
|
Everything in Intelligence, plus: dedicated onboarding, custom signal sources, quarterly strategy briefing call, SLA on scrape freshness/alerts, custom branded PDF reports. Per-seat watchlist cap: 3 (same as Intelligence — not a differentiator at this tier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Build order note:** per CLAUDE.md §27, none of Discover/Intelligence/Enterprise gets built until Analyse has paying clients — this is spec-ahead-of-build. The per-seat watchlist caps for Discover/Intelligence/Enterprise (2/3/3) are placeholders, easy to revisit before those tiers actually ship.
|
||||||
Loading…
Reference in New Issue