TinyFish parallel agents are cloud-based browser sessions that run simultaneously across multiple pharmacy websites and return structured pricing data — product name, price, dosage form, and stock status — normalized into a consistent schema for side-by-side comparison.
GoodRx works well for US consumers looking up drug prices at major chains. For developers building price transparency tools in other markets — or for any market where pharmacy pricing APIs don't exist — the only real-time data source is the pharmacy's own website. And pharmacy websites don't offer APIs.
This tutorial builds a real-time medicine price comparison tool using parallel TinyFish agents, using Vietnamese pharmacy chains as the concrete example. The architecture generalizes to any market: replace the pharmacy URLs and update the currency normalization logic.
Build a medicine price comparison tool in 4 steps:
- Define your pharmacy list with search URLs
- Write a goal prompt that extracts consistent fields across different pharmacy sites
- Run agents in parallel with
Promise.allSettled - Normalize price strings and rank by cost
Why Pharmacy Price Comparison Is Hard to Build
Pharmacy pricing is inherently fragmented. In most markets, chains set their own prices independently — and they don't publish those prices through APIs.
The engineering problems stack up quickly:
- No unified API — unlike retail (Amazon Product Advertising API) or travel (OTA affiliate APIs), pharmacy chains don't have a developer ecosystem. Each chain is its own data island.
- Price variation is real and fast-moving — prices change with promotions, supplier contracts, and regional stock. Data from last week is often wrong. Real-time extraction is the only reliable source.
- Sequential scraping multiplies wait time — checking 5 pharmacy chains one after another means 5× the latency. A user waiting for a price comparison shouldn't wait 5× longer because the architecture is sequential.
Parallel browser agents solve all three: no API required, results are real-time, and checking 5 chains takes the same wall-clock time as checking one.
| Approach | Best when | Breaks when |
|---|---|---|
| Parallel browser agents | No API exists, multi-market, real-time data needed | High-frequency automated runs (100+/hour) |
| Sequential scraping | Single pharmacy, low request volume | Latency is 5× longer per chain at scale |
| Third-party API (GoodRx, etc.) | US market, high-volume production use | API unavailable in your market or too expensive |
Access scope: This tool reads only public product search pages — the same pricing data any consumer sees when visiting a pharmacy website. It does not access prescription systems, patient portals, or any authenticated pharmacy resources.
For healthcare developers, this pattern applies to any pharmacy market. The example below uses five Vietnamese chains — Long Chau, Pharmacity, An Khang, Guardian, and Medicare — because Vietnam has no pharmacy price API and a fragmented pricing landscape. The same code runs for pharmacy chains in any country.
Choosing the right TinyFish API for this project: TinyFish offers two tools with different cost profiles. The Fetch API (api.fetch.tinyfish.ai) retrieves a page at a known URL — 1 credit = 15 pages. The Agent API (agent.tinyfish.ai) handles pages requiring a search query or navigation — 1 credit per step. Most pharmacy price lookups require a search step before prices appear, so this tutorial uses Agent API. If a product has a stable direct URL on a given pharmacy's site, Fetch API is significantly cheaper for that call.
The Parallel Agent Architecture
One agent per pharmacy, all running simultaneously. Each agent navigates to the pharmacy's search page, finds the medicine, and returns structured data. Promise.allSettled ensures that a slow or temporarily unavailable pharmacy doesn't delay results from the others.
Prerequisites: Node.js 18+, TypeScript, and a TinyFish API key.
npm install @tiny-fish/sdk
npm install -D ts-node typescript
export TINYFISH_API_KEY=your_key_here
Place index.ts and normalize.ts in the same directory, then run:
npx ts-node index.ts
import { TinyFish } from "@tiny-fish/sdk";
import { normalizePharmacyResult } from "./normalize";
const client = new TinyFish(); // reads TINYFISH_API_KEY from env
// URLs verified May 2026 — verify before deploying, as pharmacy search paths change
const PHARMACIES = [
{ name: "Long Chau", url: "https://pharmacy-a.example.com/tim-kiem?key=" },
{ name: "Pharmacity", url: "https://pharmacy-b.example.com/search?q=" },
{ name: "An Khang", url: "https://pharmacy-c.example.com/catalogsearch/result?q=" },
{ name: "Guardian", url: "https://pharmacy-d.example.com/search?query=" },
{ name: "Medicare", url: "https://pharmacy-e.example.com/search?s=" },
];
const buildGoal = (medicineName: string): string => `
Search for "${medicineName}".
Find the medicine in the search results and return a JSON object with:
- productName: string (exact name as displayed)
- price: string (price exactly as shown, including currency symbols and separators)
- currency: "VND"
- dosageForm: string (tablet, capsule, liquid — in Vietnamese or English as shown)
- stockStatus: string (in stock / out of stock — in Vietnamese or English as shown)
- url: string (direct product page link)
Return the single most relevant result for "${medicineName}".
If no match is found, return null.
`;
async function compareMedicinePrices(medicineName: string) {
const query = encodeURIComponent(medicineName);
const requests = PHARMACIES.map((pharmacy) =>
client.agent
.run({
url: pharmacy.url + query,
goal: buildGoal(medicineName),
})
.then((response) => {
// response.result is the parsed JavaScript value returned by the agent.
// null is a valid result — the medicine was not found at this pharmacy.
const raw = response.result as { productName?: string; price?: string; dosageForm?: string; stockStatus?: string; url?: string } | null;
return {
pharmacy: pharmacy.name,
result: raw ? normalizePharmacyResult(raw) : null,
};
})
);
const settled = await Promise.allSettled(requests);
const results = settled.map((r, i) => ({
pharmacy: PHARMACIES[i].name,
// error: true = infrastructure failure (timeout/network), distinct from null result (not found)
...(r.status === "fulfilled" ? r.value : { result: null, error: true }),
}));
// Sort found results by price (ascending), unfound results last
return results.sort((a, b) => {
if (!a.result?.priceVnd) return 1;
if (!b.result?.priceVnd) return -1;
return a.result.priceVnd - b.result.priceVnd;
});
}
// Usage
const results = await compareMedicinePrices("Paracetamol 500mg");
results.forEach((r) => {
if (r.result) {
console.log(`${r.pharmacy}: ${r.result.priceVnd?.toLocaleString()}₫ — ${r.result.stockStatus}`);
} else {
console.log(`${r.pharmacy}: not found${r.error ? " (error)" : ""}`);
}
});
Concurrency note: The Free plan supports 2 concurrent agent runs — 5 pharmacies run in approximately 3 batches. The Starter plan (10 concurrent) runs all five simultaneously. Each agent step consumes 1 credit — a typical product search runs 3–6 steps.
Price Normalization — The Real Engineering Challenge
The parallel agents are the easy part. Price strings are the hard part.
Five pharmacy websites display the same price in five different formats:
Long Chau: "125,000₫"
Pharmacity: "125.000 VNĐ"
An Khang: "125.000đ"
Guardian: "125,000"
Medicare: "125000 đồng"
All five mean the same thing: 125,000 Vietnamese dong. But if you put these strings side by side without normalization, they're incomparable. The sort function breaks. The low-price highlight breaks. The entire comparison breaks.
Create normalize.ts:
// normalize.ts
function parseVndPrice(raw: string): number | null {
// Remove all currency labels and symbols
const stripped = raw
.replace(/₫|VNĐ|VND|đồng|dong|đ/gi, "")
.trim();
// VND uses either comma or dot as the thousands separator.
// Key insight: if there's no decimal component after the separator,
// it's a thousands separator — not a decimal point.
// "125,000" → 125000. "125.000" → 125000. "125000" → 125000.
const digits = stripped.replace(/[^0-9]/g, "");
const num = parseInt(digits, 10);
return isNaN(num) ? null : num;
}
const DOSAGE_MAP: Record<string, string> = {
"viên nén": "tablet",
"viên nang": "capsule",
viên: "tablet",
ống: "vial",
gói: "sachet",
chai: "bottle",
hộp: "box",
};
function normalizeDosageForm(raw: string): string {
const lower = raw.toLowerCase();
for (const [vi, en] of Object.entries(DOSAGE_MAP)) {
if (lower.includes(vi)) return en;
}
return raw;
}
function normalizeStockStatus(
raw: string
): "in_stock" | "out_of_stock" | "limited" | "unknown" {
const lower = raw.toLowerCase();
if (lower.includes("còn hàng") || lower.includes("in stock")) return "in_stock";
if (lower.includes("hết hàng") || lower.includes("out of stock")) return "out_of_stock";
if (lower.includes("sắp hết") || lower.includes("limited")) return "limited";
return "unknown";
}
export function normalizePharmacyResult(raw: {
productName?: string;
price?: string;
dosageForm?: string;
stockStatus?: string;
url?: string;
}) {
return {
productName: raw.productName ?? null,
priceVnd: raw.price ? parseVndPrice(raw.price) : null,
currency: "VND",
dosageForm: raw.dosageForm ? normalizeDosageForm(raw.dosageForm) : null,
stockStatus: raw.stockStatus ? normalizeStockStatus(raw.stockStatus) : "unknown",
url: raw.url ?? null,
};
}
Why capture price as a string first. Asking the agent to return the price as a number risks losing the formatting context needed to parse it correctly. "125.000" is ambiguous: is that 125,000 VND or 125.00 USD? Capturing the raw display string — "125.000 VNĐ" — preserves the currency label and makes the parsing logic unambiguous. Parse on your side, where you know the market context.
Generalizing to other currencies. Update parseVndPrice for your local format. European currencies often use period as thousands separator and comma as decimal (€1.250,50). US/UK use comma as thousands separator. The normalization pattern is the same — strip labels, identify the separator convention for your market, parse to integer or float.
Adapting the Pattern to Your Pharmacy Market
To adapt for a different country:
-
Replace
PHARMACIESURLs with local chain search pages — any URL that accepts a medicine name query parameter works - Update the goal prompt if your market uses different field terminology
-
Update
parseVndPricefor local currency format and separators -
Update
DOSAGE_MAPif the language differs (the agent extracts in whatever language the site uses)
Extension: price alert emails. Run the comparison on a schedule and compare with the previous day's output. When a price drops below a threshold, send an email via nodemailer or a transactional email service.
Extension: brand vs. generic comparison. Add a brandType field to the goal prompt: "brandType: 'brand' | 'generic' | 'unknown' (based on whether the product name contains the generic INN or is a brand name)".
Extension: trend tracking. Save each run's output to a database table keyed by (pharmacy, productName, date). Querying across dates gives you a price trend line per pharmacy — useful for transparency reports or consumer price alerts.
For the same parallel extraction pattern applied to other multi-site scenarios, see how this pattern works for hotel price comparison across booking platforms.
Pharmacy pricing is one of the most fragmented data problems in healthcare — hundreds of chains, no unified API, prices that change daily. Parallel agents solve the fragmentation: five chains, one function call, real-time results. The normalization layer solves the format problem: five different price strings, one comparable number. Together, they're the foundation of any serious price transparency tool.
FAQ
Can this build a medicine price comparison tool without pharmacy APIs?
Yes. This tool reads public product search pages — the same pricing data any consumer sees when visiting the pharmacy's website without logging in. No pharmacy API partnership, data licensing agreement, or institutional approval is required. This is the practical solution for markets where pharmacy price APIs don't exist.
How does the price normalization handle different currency formats?
Pharmacy websites display prices in different formats even within the same country. The parseVndPrice function strips currency labels and symbols, then removes all non-digit characters to extract the numeric value. For VND, this handles "125,000₫", "125.000 VNĐ", and "125000 đồng" identically. For other currencies, update the label strip list and handle the local decimal/thousands separator convention in your parsing function.
Why capture the price as a string from the agent rather than asking for a number?
Asking the agent to return a number risks losing the formatting context needed to parse correctly. "125.000" is ambiguous — it's 125,000 VND in Vietnam but 125.0 in USD notation. Capturing the raw display string ("125.000 VNĐ") preserves the currency label and makes your parsing logic unambiguous. Parse on your side where you know the market context.
What happens when a pharmacy doesn't carry the medicine?
null is a valid agent result — the medicine wasn't found. This is distinct from error: true, which indicates an infrastructure failure (network timeout, session error). Null results appear at the bottom of sorted output, allowing found results to float to the top regardless of which pharmacies carry the medicine.
How do I add more pharmacy chains?
Add entries to the PHARMACIES array with the chain name and its search URL. The goal prompt's natural language description adapts to different site structures — you may need to adjust field names in the goal if a chain uses unusual terminology for stock status or dosage form.
Can I schedule this for daily price tracking?
Yes. Run the comparison with a cron job or cloud scheduler and save results to a database table keyed by pharmacy, product name, and date. Comparing today's output with yesterday's automatically surfaces price changes. Set a threshold (e.g., any price drop > 10%) to trigger an email alert via a transactional email service.
Related Reading:
- Build a Hotel Price Comparison Tool for Events and Conventions
- Build a Video Game Price Comparison Tool Across 10 Platforms
Deploy This with a Free Account
The complete workflow above runs on TinyFish's free tier. 500 free steps, no credit card — enough to deploy this project and validate it against real data before choosing a plan.
Want to scrape the web without getting blocked? Try TinyFish — a browser API built for AI agents and developers.

Top comments (0)