Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "ammus",
"version": "1.0.1",
"description": "Ammo price comparison for Finnish retailers — 25 stores, 5 calibers, updated automatically",
"description": "Ammo price comparison for Finnish retailers, updated automatically",
"author": "laveez",
"license": "MIT",
"type": "module",
Expand All @@ -28,8 +28,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"preview": "vite preview",
"fetch-prices": "tsx scripts/fetch-prices.ts",
"discover-variants": "tsx scripts/discover-variants.ts"
"fetch-prices": "tsx scripts/fetch-prices.ts"
},
"dependencies": {
"react": "^19.2.6",
Expand Down
19 changes: 19 additions & 0 deletions scripts/adapters/_legacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type {Adapter} from '../lib/adapter.js';

/**
* Build a "legacy" adapter for a retailer that doesn't yet have its own
* discovery or delivery scraping. Returns `null` from both, which tells the
* orchestrator to retain existing URLs unconditionally (no auto-prune) and
* fall back to whatever's in retailers.json for delivery.
*
* As we implement per-retailer adapters, replace these with full adapters in
* their own files.
*/
export function legacyAdapter(name: string, baseUrl: string): Adapter {
return {
name,
baseUrl,
discover: async () => null,
delivery: async () => null,
};
}
18 changes: 18 additions & 0 deletions scripts/adapters/aawee.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {byCategory, searchAdapter} from '../lib/adapter.js';

export const aawee = searchAdapter({
name: 'Aawee',
baseUrl: 'https://www.aawee.fi',
productUrlPattern: /aawee\.fi\/.*\/p\/[^/]+\/?$/,
categoryUrls: byCategory({
'22 LR': ['/fi/patruunat-ja-jalleenlataus/22lr/'],
'222 Remington': ['/fi/patruunat-ja-jalleenlataus/222rem/'],
'223 Remington': ['/fi/patruunat-ja-jalleenlataus/223rem/'],
'6.5 Creedmoor': ['/fi/patruunat-ja-jalleenlataus/65creedmoor/'],
'30-06 Springfield': ['/fi/patruunat-ja-jalleenlataus/3006spring/'],
'308 Winchester': ['/fi/patruunat-ja-jalleenlataus/308win/'],
'7.62x39': ['/fi/patruunat-ja-jalleenlataus/762x39/'],
'9mm': ['/fi/patruunat-ja-jalleenlataus/9mm-patruunat/'],
}),
shippingPath: '/fi/toimitusehdot',
});
11 changes: 11 additions & 0 deletions scripts/adapters/arcis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';
import {CALIBER_QUERIES_SHORT} from '../lib/queries.js';

// Wix store. Search engine applies AND-logic, so the shorter no-"patruuna"
// caliber query is the only thing that returns results.
export const arcis = searchAdapter({
name: 'Arcis',
baseUrl: 'https://www.arcis.fi',
productUrlPattern: /arcis\.fi\/product-page\/[^?#]+/,
categoryUrls: bySearch(q => `/shop?searchTerm=${q}`, CALIBER_QUERIES_SHORT),
});
9 changes: 9 additions & 0 deletions scripts/adapters/asejaera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const asejaera = searchAdapter({
name: 'Ase ja Erä',
baseUrl: 'https://www.asejaera.fi',
productUrlPattern: /asejaera\.fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
8 changes: 8 additions & 0 deletions scripts/adapters/asenurkka.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const asenurkka = searchAdapter({
name: 'Asenurkka',
baseUrl: 'https://asenurkka.fi',
productUrlPattern: /asenurkka\.fi\/kauppa\/[^?#]+\/$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
});
11 changes: 11 additions & 0 deletions scripts/adapters/asepajavuorela.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

// /kauppa/patruunat/ in the regex filters out non-ammo hits (gun safes,
// optics, accessories) that the WP search would otherwise return.
export const asepajavuorela = searchAdapter({
name: 'Asepaja Vuorela',
baseUrl: 'https://www.asepajamvuorela.fi',
productUrlPattern: /asepajamvuorela\.fi\/kauppa\/patruunat\/[^?#]+\/$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
shippingPath: '/asiakaspalvelu/toimitus',
});
9 changes: 9 additions & 0 deletions scripts/adapters/erahaijaa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const erahaijaa = searchAdapter({
name: 'Era Haijaa',
baseUrl: 'https://era.haijaa.fi',
productUrlPattern: /haijaa\.fi\/.+\/p\/I[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
9 changes: 9 additions & 0 deletions scripts/adapters/erakala.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const erakala = searchAdapter({
name: 'Erakala',
baseUrl: 'https://www.erakala.fi',
productUrlPattern: /erakala\.fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
8 changes: 8 additions & 0 deletions scripts/adapters/erakolmio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const erakolmio = searchAdapter({
name: 'Erakolmio',
baseUrl: 'https://www.erakolmio.com',
productUrlPattern: /erakolmio\.com\/tuote\/[^/?#]+\/$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
});
9 changes: 9 additions & 0 deletions scripts/adapters/greentrail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const greentrail = searchAdapter({
name: 'Greentrail',
baseUrl: 'https://www.greentrail.fi',
productUrlPattern: /greentrail\.fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
85 changes: 85 additions & 0 deletions scripts/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type {Adapter} from '../lib/adapter.js';
import {legacyAdapter} from './_legacy.js';
import {aawee} from './aawee.js';
import {arcis} from './arcis.js';
import {asejaera} from './asejaera.js';
import {asenurkka} from './asenurkka.js';
import {asepajavuorela} from './asepajavuorela.js';
import {erahaijaa} from './erahaijaa.js';
import {erakala} from './erakala.js';
import {erakolmio} from './erakolmio.js';
import {greentrail} from './greentrail.js';
import {ironpoint} from './ironpoint.js';
import {karkkainen} from './karkkainen.js';
import {kurre} from './kurre.js';
import {metsoase} from './metsoase.js';
import {motonet} from './motonet.js';
import {olkkonen} from './olkkonen.js';
import {oulunase} from './oulunase.js';
import {pyorapaja} from './pyorapaja.js';
import {ruoto} from './ruoto.js';
import {savonmutka} from './savonmutka.js';
import {seponurheilujapatruuna} from './seponurheilujapatruuna.js';
import {sissos} from './sissos.js';
import {uittokalusto} from './uittokalusto.js';
import {verkkoaita} from './verkkoaita.js';

/**
* Registry of all known retailer adapters. Order has no semantic meaning —
* the orchestrator runs them in parallel up to CONCURRENCY.
*
* To add a retailer:
* 1. Create `adapters/<name>.ts` exporting an `Adapter`. Visit the live site
* and confirm the category page URL + product URL pattern before writing
* the adapter — guessed paths produce silent failures and noisy CI logs.
* 2. Import + add to this array.
* 3. (optional) Add a real `delivery` block to `src/data/retailers.json` if
* you've confirmed the price/threshold; otherwise leave delivery null and
* let the adapter's `delivery()` populate it on next CI run.
*/
export const adapters: Adapter[] = [
// Adapters with discover() + delivery() implemented and URLs verified
aawee,
arcis,
asejaera,
asenurkka,
asepajavuorela,
erahaijaa,
erakala,
erakolmio,
greentrail,
ironpoint,
karkkainen,
kurre,
metsoase,
motonet,
olkkonen,
oulunase,
pyorapaja,
ruoto,
savonmutka,
seponurheilujapatruuna,
sissos,
uittokalusto,
verkkoaita,

// Legacy stubs — preserve current behavior. Reasons each is still a stub
// are tracked in the PR description; replace with a real adapter once the
// underlying site issue is resolved.
legacyAdapter('Ahtihuvila', 'https://www.ahtihuvila.fi'),
legacyAdapter('Asejaosa', 'https://www.asejaosa.fi'),
legacyAdapter('Asetalo', 'https://www.asetalo.fi'),
legacyAdapter('Riistamaa', 'https://www.riistamaa.fi'),
legacyAdapter('Viranomainen', 'https://viranomainen.fi'),

// New-retailer stubs (no products yet, storefront structure unconfirmed)
legacyAdapter('Commando', 'https://www.commando.fi'),
legacyAdapter('Eräkellari', 'https://www.erakellari.fi'),
legacyAdapter('Välineet Kuntoon', 'https://valineetkuntoon.fi'),
legacyAdapter('XXL', 'https://www.xxl.fi'),
legacyAdapter('Ähtärin Ase', 'https://www.ahtarinase.fi'),
];

export function findAdapter(name: string): Adapter | undefined {
return adapters.find(a => a.name === name);
}
23 changes: 23 additions & 0 deletions scripts/adapters/ironpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {searchAdapter} from '../lib/adapter.js';

const PISTOL_CATEGORY = '/fi/pistoolin-patruunat-c-6_7.html';
const RIFLE_CATEGORY = '/fi/kivrin-patruunat-c-6_54.html';

// Iron Point's osCommerce category tree splits ammo by long-gun vs pistol,
// not by individual caliber, so calibers of the same family share a listing.
const RIFLE_CALIBERS = new Set([
'22 LR', '222 Remington', '223 Remington', '6.5 Creedmoor',
'30-06 Springfield', '308 Winchester', '7.62x39',
]);

export const ironpoint = searchAdapter({
name: 'Iron Point',
baseUrl: 'https://www.ironpoint.fi',
productUrlPattern: /ironpoint\.fi\/(fi\/)?shop\/product\/[^?#]+/,
categoryUrls: caliber => {
if (caliber === '9mm') return [PISTOL_CATEGORY];
if (RIFLE_CALIBERS.has(caliber)) return [RIFLE_CATEGORY];
return [];
},
shippingPath: '/fi/pages.php?page=toimitusehdot',
});
12 changes: 12 additions & 0 deletions scripts/adapters/karkkainen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

// Kärkkäinen has deep paginated category pages, so search-based discovery is
// the practical choice. Product URLs live at both /verkkokauppa/<slug> and
// /en-en/<slug>.
export const karkkainen = searchAdapter({
name: 'Kärkkäinen',
baseUrl: 'https://www.karkkainen.com',
productUrlPattern: /karkkainen\.com\/(verkkokauppa|en-en)\/[^/?]+(?:\?|$|\/)/,
categoryUrls: bySearch(q => `/verkkokauppa?search=${q}`),
shippingPath: '/asiakaspalvelu/toimitustavat',
});
9 changes: 9 additions & 0 deletions scripts/adapters/kurre.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const kurre = searchAdapter({
name: 'Kurre',
baseUrl: 'https://www.kurre.fi',
productUrlPattern: /kurre\.fi\/fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/fi/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
9 changes: 9 additions & 0 deletions scripts/adapters/metsoase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const metsoase = searchAdapter({
name: 'Metso Ase',
baseUrl: 'https://www.metsoase.fi',
productUrlPattern: /metsoase\.fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
42 changes: 42 additions & 0 deletions scripts/adapters/motonet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type {Page} from 'playwright';
import type {Adapter} from '../lib/adapter.js';
import {discoverByPattern, fetchDelivery} from '../lib/discovery.js';
import type {DeliveryRule, DiscoveredProduct} from '../lib/types.js';

const BASE_URL = 'https://www.motonet.fi';

// Motonet uses a top-level /haku?q=<query> search; their category tree URLs
// require a category UUID parameter that's not stable. Search-based discovery
// is the practical option.
const CALIBER_QUERIES: Record<string, string> = {
'22 LR': '22+lr+patruuna',
'222 Remington': '222+rem+patruuna',
'223 Remington': '223+rem+patruuna',
'6.5 Creedmoor': '6.5+creedmoor+patruuna',
'30-06 Springfield': '30-06+sprg+patruuna',
'308 Winchester': '308+win+patruuna',
'7.62x39': '7.62x39+patruuna',
'9mm': '9mm+luger+patruuna',
};

export const motonet: Adapter = {
name: 'Motonet',
baseUrl: BASE_URL,

async discover(page: Page, caliber: string): Promise<DiscoveredProduct[] | null> {
const q = CALIBER_QUERIES[caliber];
if (!q) return [];
return discoverByPattern(page, {
caliber,
baseUrl: BASE_URL,
categoryUrls: [`${BASE_URL}/haku?q=${q}`],
productUrlPattern: /motonet\.fi\/tuote\/[^?]+\?product=\d+/,
});
},

async delivery(page: Page): Promise<DeliveryRule | null> {
return fetchDelivery(page, {
shippingUrl: `${BASE_URL}/info/toimitustavat`,
});
},
};
11 changes: 11 additions & 0 deletions scripts/adapters/olkkonen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';
import {CALIBER_QUERIES_SHORT} from '../lib/queries.js';

// Olkkonen is a Wix store; its search uses AND-logic so the shorter no-
// "patruuna" caliber query returns the most results.
export const olkkonen = searchAdapter({
name: 'Olkkonen',
baseUrl: 'https://www.olkkonen.fi',
productUrlPattern: /olkkonen\.fi\/fi\/product\/[^/?#]+/,
categoryUrls: bySearch(q => `/fi/shop?searchTerm=${q}`, CALIBER_QUERIES_SHORT),
});
9 changes: 9 additions & 0 deletions scripts/adapters/oulunase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const oulunase = searchAdapter({
name: 'Oulun Ase',
baseUrl: 'https://www.oulunase.fi',
productUrlPattern: /oulunase\.fi\/product\/\d+\/[^/?#]+$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
shippingPath: '/page/1/toimitusehdot',
});
8 changes: 8 additions & 0 deletions scripts/adapters/pyorapaja.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const pyorapaja = searchAdapter({
name: 'Pyörä-Paja',
baseUrl: 'https://www.pyorapaja.fi',
productUrlPattern: /pyorapaja\.fi\/product\/\d+/,
categoryUrls: bySearch(q => `/search/?q=${q}`),
});
9 changes: 9 additions & 0 deletions scripts/adapters/ruoto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const ruoto = searchAdapter({
name: 'Ruoto',
baseUrl: 'https://www.ruoto.fi',
productUrlPattern: /ruoto\.fi\/.+\/p\/[^/]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/toimitusehdot',
});
9 changes: 9 additions & 0 deletions scripts/adapters/savonmutka.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const savonmutka = searchAdapter({
name: 'Savon Mutka',
baseUrl: 'https://savonmutka.fi',
productUrlPattern: /savonmutka\.fi\/product\/[^/?#]+\/?$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
shippingPath: '/sopimusehdot',
});
9 changes: 9 additions & 0 deletions scripts/adapters/seponurheilujapatruuna.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const seponurheilujapatruuna = searchAdapter({
name: 'Seponurheilujapatruuna',
baseUrl: 'https://www.seponurheilujapatruuna.fi',
productUrlPattern: /seponurheilujapatruuna\.fi\/tuote\/[^/?#]+\/$/,
categoryUrls: bySearch(q => `/?s=${q}&post_type=product`),
shippingPath: '/asiakaspalvelu/toimitus',
});
9 changes: 9 additions & 0 deletions scripts/adapters/sissos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {bySearch, searchAdapter} from '../lib/adapter.js';

export const sissos = searchAdapter({
name: 'Sissos',
baseUrl: 'https://www.sissos.fi',
productUrlPattern: /sissos\.fi\/.*\/p\/[A-Za-z0-9_]+\/?$/,
categoryUrls: bySearch(q => `/haku?q=${q}`),
shippingPath: '/i/toimitusehdot/',
});
Loading