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
38 changes: 36 additions & 2 deletions api/dist/routes/products.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,24 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe
FROM products
LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id
${whereClause}
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) DESC, updated_at DESC
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) * CASE
WHEN lower(title) LIKE '%laptop%'
AND lower(title) NOT LIKE '%sleeve%'
AND lower(title) NOT LIKE '%case%'
AND lower(title) NOT LIKE '%bag%'
AND lower(title) NOT LIKE '%stand%'
AND lower(title) NOT LIKE '%pad%'
AND lower(title) NOT LIKE '%cooler%'
AND lower(title) NOT LIKE '%adapter%'
AND lower(title) NOT LIKE '%dock%'
AND lower(title) NOT LIKE '%hub%'
AND lower(title) NOT LIKE '%lock%'
AND lower(title) NOT LIKE '%briefcase%'
AND lower(title) NOT LIKE '%charger%'
AND lower(title) NOT LIKE '%table%'
THEN 2.0
ELSE 1.0
END DESC, updated_at DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
}
Expand All @@ -213,7 +230,24 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe
${whereClause}
LIMIT ${CANDIDATE_LIMIT}
) _candidates
ORDER BY rank DESC
ORDER BY rank * CASE
WHEN lower(title) LIKE '%laptop%'
AND lower(title) NOT LIKE '%sleeve%'
AND lower(title) NOT LIKE '%case%'
AND lower(title) NOT LIKE '%bag%'
AND lower(title) NOT LIKE '%stand%'
AND lower(title) NOT LIKE '%pad%'
AND lower(title) NOT LIKE '%cooler%'
AND lower(title) NOT LIKE '%adapter%'
AND lower(title) NOT LIKE '%dock%'
AND lower(title) NOT LIKE '%hub%'
AND lower(title) NOT LIKE '%lock%'
AND lower(title) NOT LIKE '%briefcase%'
AND lower(title) NOT LIKE '%charger%'
AND lower(title) NOT LIKE '%table%'
THEN 2.0
ELSE 1.0
END DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
}
Expand Down
10 changes: 8 additions & 2 deletions api/src/jobs/priceRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export async function runPriceRefresh(): Promise<RefreshSummary> {
}
}

// Update captured_at for each product regardless of scraper result
// Update retailer_prices.captured_at + products.updated_at for each product (BUY-59843)
for (const { product_id, slug } of items) {
try {
await db.query(
Expand All @@ -134,6 +134,12 @@ export async function runPriceRefresh(): Promise<RefreshSummary> {
WHERE product_id = $1`,
[product_id]
);
await db.query(
`UPDATE products
SET updated_at = NOW()
WHERE id = $1::bigint`,
[product_id]
);
results.push({
product_id,
platform,
Expand All @@ -143,7 +149,7 @@ export async function runPriceRefresh(): Promise<RefreshSummary> {
error: scraperError && SCRAPER_URL ? scraperError : undefined,
scraper_triggered,
});
console.log(`[price-refresh] ✓ captured_at updated for ${slug} (${product_id})`);
console.log(`[price-refresh] ✓ retailer_prices.captured_at + products.updated_at set for ${slug} (${product_id})`);
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
results.push({ product_id, platform, sku: product_id, slug, success: false, error, scraper_triggered });
Expand Down
38 changes: 36 additions & 2 deletions api/src/routes/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,24 @@ router.get(
FROM products
LEFT JOIN affiliate_links al ON al.product_id = products.id::text AND al.merchant_id = products.merchant_id
${whereClause}
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) DESC, updated_at DESC
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $${ftsParamIdx})) * CASE
WHEN lower(title) LIKE '%laptop%'
AND lower(title) NOT LIKE '%sleeve%'
AND lower(title) NOT LIKE '%case%'
AND lower(title) NOT LIKE '%bag%'
AND lower(title) NOT LIKE '%stand%'
AND lower(title) NOT LIKE '%pad%'
AND lower(title) NOT LIKE '%cooler%'
AND lower(title) NOT LIKE '%adapter%'
AND lower(title) NOT LIKE '%dock%'
AND lower(title) NOT LIKE '%hub%'
AND lower(title) NOT LIKE '%lock%'
AND lower(title) NOT LIKE '%briefcase%'
AND lower(title) NOT LIKE '%charger%'
AND lower(title) NOT LIKE '%table%'
THEN 2.0
ELSE 1.0
END DESC, updated_at DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
} else if (useFtsRanking) {
Expand All @@ -224,7 +241,24 @@ router.get(
${whereClause}
LIMIT ${CANDIDATE_LIMIT}
) _candidates
ORDER BY rank DESC
ORDER BY rank * CASE
WHEN lower(title) LIKE '%laptop%'
AND lower(title) NOT LIKE '%sleeve%'
AND lower(title) NOT LIKE '%case%'
AND lower(title) NOT LIKE '%bag%'
AND lower(title) NOT LIKE '%stand%'
AND lower(title) NOT LIKE '%pad%'
AND lower(title) NOT LIKE '%cooler%'
AND lower(title) NOT LIKE '%adapter%'
AND lower(title) NOT LIKE '%dock%'
AND lower(title) NOT LIKE '%hub%'
AND lower(title) NOT LIKE '%lock%'
AND lower(title) NOT LIKE '%briefcase%'
AND lower(title) NOT LIKE '%charger%'
AND lower(title) NOT LIKE '%table%'
THEN 2.0
ELSE 1.0
END DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
} else {
Expand Down
181 changes: 181 additions & 0 deletions buywhere-api-buy-22757/api/src/routes/redirect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Router, Request, Response } from 'express';
import { createHash } from 'crypto';
import { db } from '../config';
import { trackAffiliateClick } from '../analytics/posthog';

function hashKey(rawKey: string): string {
return createHash('sha256').update(rawKey).digest('hex');
}

const router = Router();

// Awin affiliate programme (BUY-6873)
const awinPublisherId = process.env.AWIN_PUBLISHER_ID || '';
const awinAdvertiserIds: Set<string> = new Set(
(process.env.AWIN_ADVERTISER_IDS || '').split(',').map((id) => id.trim()).filter(Boolean)
);

function buildAwinUrl(advertiserId: string, destination: string, clickRef: string): string {
const encoded = encodeURIComponent(destination);
return `https://www.awin1.com/cread.php?awinmid=${advertiserId}&awinaffid=${awinPublisherId}&clickref=${clickRef}&p=${encoded}`;
}

const DEFAULT_ALLOWED_DOMAINS = [
'lazada.sg',
'shopee.sg',
'bestdenki.com.sg',
'amazon.sg',
'courts.com.sg',
'harvey-norman.com.sg',
'challenger.sg',
'qoo10.sg',
];

const allowedDomains: Set<string> = new Set(
(process.env.AFFILIATE_ALLOWED_DOMAINS
? process.env.AFFILIATE_ALLOWED_DOMAINS.split(',').map((d) => d.trim())
: DEFAULT_ALLOWED_DOMAINS
).filter(Boolean)
);

function isAllowedDestination(url: string): boolean {
try {
const { hostname } = new URL(url);
const bare = hostname.replace(/^www\./, '');
return allowedDomains.has(bare);
} catch {
return false;
}
}

// GET /r/direct/:merchantId/:productId
// Direct merchant redirect without allowlist restriction (for all merchants)
router.get('/direct/:merchantId/:productId', async (req: Request, res: Response) => {
const { merchantId, productId } = req.params;

// Look up product directly by merchant_id and product_id
const productResult = await db.query(
`SELECT url, merchant_id FROM products WHERE id = $1 AND merchant_id = $2`,
[productId, merchantId]
);

if (productResult.rows.length === 0) {
res.status(404).json({ error: 'Product not found' });
return;
}

const destinationUrl = productResult.rows[0].url;
const actualMerchantId = productResult.rows[0].merchant_id || 'unknown';

if (!destinationUrl) {
res.status(404).json({ error: 'Product URL not found' });
return;
}

// Determine API key for attribution
const authHeader = req.headers['authorization'] || '';
let apiKey: string | null = null;
if (authHeader.startsWith('Bearer ')) apiKey = authHeader.slice(7).trim();
const source = req.query.source as string || 'direct_redirect';

// Log click to DB (before redirect)
await db.query(
`INSERT INTO affiliate_clicks
(api_key, affiliate_slug, product_id, merchant_id, affiliate_link_id, source, destination_url)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[apiKey, 'direct', productId, actualMerchantId, '', source, destinationUrl]
);

// PostHog event (fire-and-forget)
trackAffiliateClick({
apiKey: apiKey ? hashKey(apiKey) : null,
productId,
merchantId: actualMerchantId,
affiliateLinkId: '',
source,
});

res.redirect(302, destinationUrl);
});


// GET /r/:affiliateSlug/:productId
// Log the affiliate click then redirect to destination
router.get('/:affiliateSlug/:productId', async (req: Request, res: Response) => {
const { affiliateSlug, productId } = req.params;

// Look up affiliate link
const linkResult = await db.query(
`SELECT id, merchant_id, platform, destination_url
FROM affiliate_links WHERE platform = $1 AND product_id = $2`,
[affiliateSlug, productId]
);

let merchantId = 'unknown';
let affiliateLinkId = '';
let destinationUrl: string | null = null;

if (linkResult.rows.length > 0) {
const link = linkResult.rows[0];
merchantId = link.merchant_id || affiliateSlug;
affiliateLinkId = String(link.id);
destinationUrl = link.destination_url;
} else {
// Fallback: try direct product lookup
const productResult = await db.query(
`SELECT url, merchant_id FROM products WHERE id = $1`,
[productId]
);
if (productResult.rows.length > 0) {
destinationUrl = productResult.rows[0].url;
merchantId = productResult.rows[0].merchant_id || 'unknown';
}
}

if (!destinationUrl) {
res.status(404).json({ error: 'Affiliate link not found' });
return;
}

// Determine API key for attribution
const authHeader = req.headers['authorization'] || '';
let apiKey: string | null = null;
if (authHeader.startsWith('Bearer ')) apiKey = authHeader.slice(7).trim();
const source = req.query.source as string || 'api_response';

// Log click to DB (before redirect)
await db.query(
`INSERT INTO affiliate_clicks
(api_key, affiliate_slug, product_id, merchant_id, affiliate_link_id, source, destination_url)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[apiKey, affiliateSlug, productId, merchantId, affiliateLinkId, source, destinationUrl]
);

// PostHog event (fire-and-forget)
// Hash API key before sending to third-party analytics
trackAffiliateClick({
apiKey: apiKey ? hashKey(apiKey) : null,
productId,
merchantId,
affiliateLinkId,
source,
});

// Rewrite to Awin tracking URL when publisher + advertiser IDs are configured
let finalUrl = destinationUrl;
if (awinPublisherId && affiliateLinkId && awinAdvertiserIds.has(affiliateLinkId)) {
const clickRef = `${productId.slice(0, 12)}-${Date.now().toString(36)}`;
finalUrl = buildAwinUrl(affiliateLinkId, destinationUrl, clickRef);
} else {
if (!isAllowedDestination(destinationUrl)) {
const { hostname } = (() => { try { return new URL(destinationUrl); } catch { return { hostname: destinationUrl }; } })();
console.warn(`[redirect] blocked: hostname "${hostname}" not in allowlist`);
res.status(403).json({ error: 'Destination not permitted' });
return;
}
}

res.redirect(302, finalUrl);
});

export default router;
65 changes: 65 additions & 0 deletions deploy/scraper-scheduler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Scraper Scheduler

Keeps BuyWhere product data fresh by running scrapers on a schedule.

## Deployment

### Prerequisites

- Python 3.10+ with scraper dependencies installed
- Access to the BuyWhere API (local or production)
- A BuyWhere API key with ingest permissions

### Install

```bash
# Install Python dependencies
cd /opt/buywhere
pip install -r scrapers/requirements.txt
playwright install chromium

# Set up systemd service
sudo cp deploy/scraper-scheduler/scraper-scheduler.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable scraper-scheduler
sudo systemctl start scraper-scheduler

# Check status
sudo systemctl status scraper-scheduler
```

### Configuration

Set environment variables in the systemd service file or in `/etc/default/scraper-scheduler`:

| Variable | Default | Description |
|---|---|---|
| `BUYWHERE_API_URL` | `http://localhost:3000` | Ingest API base URL |
| `BUYWHERE_API_KEY` | _(required)_ | API key with ingest permissions |
| `SCRAPER_SCHEDULE` | _(optional)_ | JSON override: `{"amazon_us": 24}` |
| `SCRAPER_DATA_DIR` | `./data` | Directory for scraper output |

### Manual Run

```bash
# Run all scrapers once
python scripts/scraper_scheduler.py --run-once

# Run specific scrapers
python scripts/scraper_scheduler.py --run-once --scrapers amazon_us,bestbuy_us_sitemap
```

## How It Works

1. The scheduler runs as a daemon, checking each scraper's last run time
2. When a scraper is due (based on its `interval_hours`), it's launched as a subprocess
3. The scraper scrapes merchant sites and pushes data through the local API's `/v1/ingest/products` endpoint
4. The ingest endpoint upserts products, setting `products.updated_at = NOW()`
5. The nightly `priceRefresh` job also updates `products.updated_at` as a secondary freshness signal

## Monitoring

Check scheduler health:
```bash
journalctl -u scraper-scheduler -f
```
Loading