First World Prices, Third World Wages


In Argentina, the official narrative is one of victory. The government celebrates a monthly inflation rate of just 1.5%, a figure that suggests a return to economic stability after years of hyperinflation. On paper, it looks like a miracle. But for the millions of Argentinians navigating daily life, this "victory" feels hollow. The truth is in the grocery store aisles, and it tells a very different story.
This isn't a story about opinions. It's a story about data.
Frustrated by the disconnect between official statistics and the lived experience, I built a scraper to audit a real-world microcosm of the Argentine economy: Átomo, a popular supermarket chain in the province of Mendoza. I tracked the prices of thousands of products between two key dates: May 4th and June 13th, 2025.
The results expose the dangerous illusion of low inflation and reveal the true nature of Argentina's anarcho-capitalist experiment: the systematic destruction of purchasing power.
A Façade of Stability
At first glance, the data seems to support the official story. My "Átomo Price Index," calculated on a basket of 6,938 comparable products, registered a net deflation of -0.54% over the 40-day period.
A headline could read: "Supermarket prices fall in Argentina." But this headline would be a lie. That single, simple average is a statistical phantom, masking a brutal reality of market chaos.
Here's what is actually happening:
Extreme Volatility: Nearly 3,000 products—over 42% of the entire catalog—changed price in just 40 days. Stability is non-existent.
Wild Price Swings: The most extreme price hike was a staggering +150.00%, while the deepest cut was -60.40%. This is not a functioning market; it's a casino where prices are completely untethered from production costs.
The Illusion of Balance: For every product that saw a price cut, roughly 1.5 products saw a price increase. The deflationary average is a mathematical quirk, likely driven by discounts on a few high-ticket items masking the rising cost of daily essentials.
Now the decrements:
Note: USD 1 = ARS 1200
First-World Prices, Third-World Wages
This data is the real-time footprint of an economy in the grips of anarcho-capitalism. With total deregulation and a history of economic trauma, a broken system emerges:
No Price Anchors: Prices are not based on cost or value, but on a reactive mix of dollar-rate panic, future inflation expectations, and a refusal by producers to ever absorb a loss.
Dollarized Costs, Pesofied Salaries: Products are priced as if they were being sold in Zurich, but salaries remain frozen in a devalued local currency.
The Shrinkflation Deception: Alongside distorted prices, the quality and quantity of products are visibly diminishing—a hidden tax on consumers that official inflation figures never capture.
The result is a country with one of the highest consumer price points in the world relative to its income. A half-liter of Coca-Cola costs the equivalent of $3 USD. A basic dinner for one at a neighborhood restaurant is $20. These are prices on par with Europe, paid with salaries that are not.
The Human Cost
To understand the real-world impact, we must abandon abstract percentages and use tangible metrics.
The Argentine Big Mac Index: Today, Argentina's minimum wage can buy approximately 30 Big Macs. This is a stark measure of our lost purchasing power compared to developed nations where the same metric yields hundreds.
The $300 Shopping Cart: Before the current administration, a $300 monthly grocery budget could fill a family's shopping cart. Today, that same basket of goods costs nearly double, yet salaries have not followed suit. Your labor is worth less. Your ability to provide is diminished.
The Real Crisis Isn't Inflation, It's Survival
The debate over whether inflation is 1% or 5% is a deliberate distraction. It's a magic trick designed to make us focus on a single number while the entire foundation of our economic lives crumbles. Half of the people in this country are poor.
The real crisis revealed by the data is the death of predictability and the erosion of the middle class. A society cannot function when its citizens can no longer afford the basics, and when the price of milk can change dramatically from one week to the next.
This isn't an economic adjustment. It's a battle for survival. And the data shows that, right now, the average Argentinian is losing.
Misery Index:
But once in office, Milei shelved dollarization, promising that it would arrive at some future, unspecified date. Apparently, Milei was spooked by the International Monetary Fund, Argentina’s largest creditor, as well as by his own advisers, who claimed that dollarization was not feasible without the receipt of a large loan because Argentina didn’t have enough foreign-exchange reserves to dollarize.
That being said, Milei has done a lot of good in Argentina. Thanks to his progress in dismantling Argentina’s fascist economic system, the Argentine stock market is up 124 percent since Milei took office, and his approval rating is higher than former President Alberto Fernandez’s in 2023 across every level of the income distribution.
However, Milei has failed to fulfil his campaign promise. As a result, he has been unable to rein in the growth of Argentina’s money supply. It’s surging at 169 percent per year. Until Milei controls the money supply, he is living on borrowed time.
Argentina’s HAMI = [(Unemployment (7.2%) * 2) + Inflation (118%) + Bank-Lending Rate (60.6%)] — Real GDP Growth (-2.7%) = 195.9
# the complete scraper script
import httpx
from lxml import html
import sqlite3
import time
import logging
import re
import datetime
import pytz # Required for timezone handling
import os # To check if files exist
import csv # To log price changes
# --- Configuration ---
DB_PATH = "atomo.db"
PRICE_LOG_PATH = "price_changes.csv"
REQUEST_TIMEOUT = 25.0
SLEEP_BETWEEN_PAGES = 1.0
DOLAR_API_URL = "https://dolarapi.com/v1/dolares/cripto"
ARGENTINA_TZ = pytz.timezone('America/Argentina/Buenos_Aires')
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
CATEGORIES = [
("https://atomoconviene.com/atomo-ecommerce/mas-vendidos?page={}", 33),
("https://atomoconviene.com/atomo-ecommerce/300-carnes-y-congelados?page={}", 1),
("https://atomoconviene.com/atomo-ecommerce/833-ofertas?page={}", 5),
("https://atomoconviene.com/atomo-ecommerce/3-almacen?page={}", 14),
("https://atomoconviene.com/atomo-ecommerce/81-bebidas?page={}", 5),
("https://atomoconviene.com/atomo-ecommerce/226-lacteos-fiambres?page={}", 2),
("https://atomoconviene.com/atomo-ecommerce/473-sin-tacc?page={}", 2),
("https://atomoconviene.com/atomo-ecommerce/83-perfumeria?page={}", 6),
("https://atomoconviene.com/atomo-ecommerce/85-limpieza?page={}", 4),
("https://atomoconviene.com/atomo-ecommerce/82-mundo-bebe?page={}", 1),
("https://atomoconviene.com/atomo-ecommerce/88-mascotas?page={}", 1),
("https://atomoconviene.com/atomo-ecommerce/315-hogar-bazar?page={}", 1),
("https://atomoconviene.com/atomo-ecommerce/306-jugueteria-y-libreria?page={}", 3),
]
LISTING_XPATH = "//article[contains(@class,'product-miniature')]"
XPATHS = {
"PRODUCT_ID": "@data-id-product",
"PRODUCT_URL": ".//a[contains(@class,'product-thumbnail')]/@href",
"PRODUCT_NAME": ".//h2[contains(@class,'product-title')]/a/text()",
"PRODUCT_PRICE_STR": ".//span[contains(@class,'price')]/text()",
"PRODUCT_IMAGE_URL": ".//a[contains(@class,'product-thumbnail')]//img/@data-full-size-image-url | .//a[contains(@class,'product-thumbnail')]//img/@data-src | .//a[contains(@class,'product-thumbnail')]//img/@src"
}
# Logging setup for general script progress
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# --- Helper Functions ---
def get_dolar_crypto_rate():
"""Fetches the 'venta' value for Dolar Cripto from dolarapi.com."""
logging.info(f"Attempting to fetch Dolar Cripto rate from {DOLAR_API_URL}")
try:
with httpx.Client(timeout=15.0) as client:
response = client.get(DOLAR_API_URL)
response.raise_for_status()
data = response.json()
venta_rate = data.get('venta')
if venta_rate is None:
logging.error("API response missing 'venta' key.")
return None
rate = float(venta_rate)
logging.info(f"Successfully fetched Dolar Cripto 'venta' rate: {rate}")
return rate
except Exception as e:
logging.error(f"Failed to fetch or parse Dolar Cripto rate: {e}", exc_info=True)
return None
def clean_price(price_str):
"""Cleans ARS price string and converts to int if whole number, else float."""
if not price_str: return None
try:
cleaned = re.sub(r'[$\s]', '', price_str)
if '.' in cleaned and ',' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.')
elif ',' in cleaned:
cleaned = cleaned.replace(',', '.')
num_value = float(cleaned)
return int(num_value) if num_value.is_integer() else num_value
except (ValueError, TypeError) as e:
logging.warning(f"Could not parse price string: '{price_str}'. Error: {e}")
return None
def get_argentina_time_str():
"""Gets the current time adjusted to Argentina time (UTC-3) as string."""
utc_now = datetime.datetime.now(pytz.utc)
argentina_now = utc_now.astimezone(ARGENTINA_TZ)
return argentina_now.strftime('%Y-%m-%d %H:%M:%S')
# NEW: Function to calculate and print the custom IPC
import logging
def calculate_and_print_ipc(price_pairs):
"""
Calculates the average percentage change from a list of (old_price, new_price)
pairs and prints a detailed, human-readable result.
"""
if not price_pairs or len(price_pairs) < 10:
logging.warning(f"Not enough common products ({len(price_pairs)}) to calculate a reliable IPC. Skipping.")
return
# This list will store the valid percentage changes for each product.
ipc_values = []
for old_price, new_price in price_pairs:
if old_price is not None and new_price is not None and old_price > 0:
percentage_change = ((new_price - old_price) / old_price) * 100
ipc_values.append(percentage_change)
if not ipc_values:
logging.warning("No valid price changes found among the provided pairs to calculate an average.")
return
# The final, correct average calculation.
average_ipc = sum(ipc_values) / len(ipc_values)
# Adding the rich, user-friendly output formatting.
if average_ipc > 0.05:
emoji = "📈"
wording = "un AUMENTO"
final_text = f"{emoji} Átomo IPC (Inflación): Se registró {wording} de precios promedio del {average_ipc:.2f}%"
elif average_ipc < -0.05:
emoji = "📉"
wording = "una DISMINUCIÓN"
final_text = f"{emoji} Átomo IPC (Deflación): Se registró {wording} de precios promedio del {abs(average_ipc):.2f}%"
else:
emoji = "⚖️"
wording = "sin cambios significativos"
final_text = f"{emoji} Átomo IPC (Estabilidad): Se registraron {wording} en los precios ({average_ipc:.2f}%)"
print("\n" + "=" * 80)
print(final_text)
print(f" (Calculado sobre la base de {len(ipc_values)} productos con precios comparables)")
print("=" * 80 + "\n")
products_up = sum(1 for change in ipc_values if change > 0)
products_down = sum(1 for change in ipc_values if change < 0)
products_stable = sum(1 for change in ipc_values if abs(change) <= 0.05)
print(f"Productos que subieron de precio: {products_up}")
print(f"Productos que bajaron de precio: {products_down}")
print(f"Productos con precios estables (cambio <= 0.05%): {products_stable}")
max_increase = max(ipc_values)
min_decrease = min(ipc_values)
print(f"Mayor aumento de precio registrado: {max_increase:.2f}%")
print(f"Mayor disminución de precio registrada: {min_decrease:.2f}%")
# --- Database and History Setup ---
def setup_database(db_path):
"""Connects to the SQLite database and ensures the table exists."""
try:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS products
(
PRODUCT_ID TEXT PRIMARY KEY,
PRODUCT_URL TEXT,
PRODUCT_NAME TEXT,
PRODUCT_PRICE_ARS REAL,
PRODUCT_PRICE_USDC REAL,
PRODUCT_IMAGE_URL TEXT,
SCRAPED_AT TEXT
)
''')
conn.commit()
logging.info(f"Database connection established at {db_path}")
return conn, cursor
except sqlite3.Error as e:
logging.error(f"Database error during setup: {e}")
raise
def load_old_prices(conn):
"""Loads existing Product IDs and ARS prices from the DB into a dictionary."""
old_prices = {}
try:
cursor = conn.cursor()
cursor.execute("SELECT PRODUCT_ID, PRODUCT_PRICE_ARS FROM products")
for row in cursor.fetchall():
price = row[1] if row[1] is not None else None
old_prices[row[0]] = price
logging.info(f"Loaded {len(old_prices)} existing product prices from database.")
except sqlite3.Error as e:
logging.warning(f"Could not load old prices from DB (maybe first run?): {e}")
return old_prices
def log_price_change(log_path, change_details):
"""Appends a price change event to the CSV log file."""
file_exists = os.path.isfile(log_path)
try:
with open(log_path, 'a', newline='', encoding='utf-8') as csvfile:
fieldnames = ['timestamp', 'product_id', 'product_name',
'old_price_ars', 'new_price_ars', 'change_percentage', 'product_url']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
if not file_exists or os.path.getsize(log_path) == 0:
writer.writeheader()
writer.writerow(change_details)
except IOError as e:
logging.error(f"Failed to write to price change log {log_path}: {e}")
# --- Main Scraping Logic ---
def scrape_products(conn, cursor, categories_to_scrape, dolar_rate, old_prices):
"""Scrapes product data, compares prices, logs changes, and prepares data for DB update."""
all_products_for_db = []
price_change_detected_flag = False
# NEW: List to store (old_price, new_price) for IPC calculation
price_comparison_pairs = []
total_processed_pages = 0
total_products_found = 0
with httpx.Client(headers=HEADERS, timeout=REQUEST_TIMEOUT, follow_redirects=True) as client:
for base_url_template, max_pages in categories_to_scrape:
category_name = base_url_template.split('/')[-1].split('?')[0]
logging.info(f"--- Starting category: {category_name} (Max pages: {max_pages}) ---")
category_products_found_on_page = 0
for page in range(1, max_pages + 1):
url = base_url_template.format(page)
logging.info(f"Attempting to scrape page: {url}")
try:
response = client.get(url)
if response.status_code == 404:
logging.warning(f"Page {url} returned 404, ending category.")
break
response.raise_for_status()
except Exception as list_err:
logging.error(f"Error fetching list page {url}: {list_err}")
time.sleep(SLEEP_BETWEEN_PAGES * 2)
continue
try:
tree = html.fromstring(response.content)
product_elements = tree.xpath(LISTING_XPATH)
if not product_elements:
logging.info(f"No products found on page {page} of {category_name}. Ending category.")
break
total_processed_pages += 1
page_products_count = len(product_elements)
category_products_found_on_page += page_products_count
logging.info(f"Found {page_products_count} products on page {page}...")
current_scraped_at = get_argentina_time_str()
for product_el in product_elements:
product = {"SCRAPED_AT": current_scraped_at}
for key, xp in XPATHS.items():
result = product_el.xpath(xp)
raw_value = result[0] if isinstance(result, list) and result else (
result if not isinstance(result, list) else None)
product[key] = raw_value.strip() if isinstance(raw_value, str) else raw_value
product_id = product.get("PRODUCT_ID")
if not product_id:
logging.warning(f"Skipping product on page {page} due to missing PRODUCT_ID. URL: {url}")
continue
new_price_ars = clean_price(product.get("PRODUCT_PRICE_STR"))
old_price_ars = old_prices.get(product_id)
is_change = False
percentage_change = 0.0
if new_price_ars is not None and old_price_ars is not None:
# Add the pair for IPC calculation
price_comparison_pairs.append((old_price_ars, new_price_ars))
if not abs(new_price_ars - old_price_ars) < 0.01:
is_change = True
if old_price_ars > 0:
percentage_change = round(((new_price_ars - old_price_ars) / old_price_ars) * 100,
2)
else:
percentage_change = float('inf')
if is_change:
price_change_detected_flag = True
change_details = {
'timestamp': current_scraped_at,
'product_id': product_id,
'product_name': product.get("PRODUCT_NAME", "N/A"),
'old_price_ars': old_price_ars,
'new_price_ars': new_price_ars,
'change_percentage': percentage_change,
'product_url': product.get("PRODUCT_URL", "N/A")
}
log_price_change(PRICE_LOG_PATH, change_details)
logging.info(
f"PRICE CHANGE DETECTED: ID {product_id} | Old: {old_price_ars} | New: {new_price_ars} | %: {percentage_change}%")
price_usdc = None
if new_price_ars is not None and dolar_rate is not None and dolar_rate > 0:
price_usdc = round(new_price_ars / dolar_rate, 2)
product_tuple = (
product_id,
product.get("PRODUCT_URL"),
product.get("PRODUCT_NAME"),
new_price_ars,
price_usdc,
product.get("PRODUCT_IMAGE_URL"),
current_scraped_at
)
all_products_for_db.append(product_tuple)
total_products_found += 1
time.sleep(SLEEP_BETWEEN_PAGES)
except html.LxmlError as e:
logging.error(f"Parsing error on page {url}: {e}")
continue
except Exception as e:
logging.error(f"Unexpected error processing page {url}: {e}", exc_info=True)
continue
logging.info(
f"--- Finished category '{category_name}'. Found {category_products_found_on_page} products in this category run. ---")
if all_products_for_db:
logging.info(f"Processed {total_products_found} product listings across {total_processed_pages} pages.")
logging.info(
f"Attempting to insert/update {len(all_products_for_db)} product records into the database (includes duplicates from list).")
try:
cursor.executemany('''
INSERT OR REPLACE INTO products
(PRODUCT_ID, PRODUCT_URL, PRODUCT_NAME, PRODUCT_PRICE_ARS, PRODUCT_PRICE_USDC, PRODUCT_IMAGE_URL, SCRAPED_AT)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', all_products_for_db)
conn.commit()
cursor.execute("SELECT COUNT(*) FROM products")
final_db_count = cursor.fetchone()[0]
logging.info(f"Database update complete. Final unique product count in DB: {final_db_count}")
except sqlite3.Error as e:
logging.error(f"Database error during bulk insert: {e}")
conn.rollback()
else:
logging.info("No product data collected to update database.")
# MODIFIED: Return both the flag and the new list of price pairs
return price_change_detected_flag, price_comparison_pairs
# --- Main Execution ---
if __name__ == "__main__":
logging.info("Starting scraper script...")
current_dolar_rate = get_dolar_crypto_rate()
conn = None
changes_found = False
# NEW: Initialize list to hold IPC data
price_pairs_for_ipc = []
try:
conn, cursor = setup_database(DB_PATH)
old_prices_data = load_old_prices(conn)
# MODIFIED: Capture both return values from the function
changes_found, price_pairs_for_ipc = scrape_products(conn, cursor, CATEGORIES, current_dolar_rate,
old_prices_data)
except sqlite3.Error as db_err:
logging.error(f"A database error occurred: {db_err}", exc_info=True)
except Exception as e:
logging.error(f"A critical unexpected error occurred: {e}", exc_info=True)
finally:
if conn:
logging.info("Closing database connection.")
conn.close()
# NEW: Call the IPC calculation function before the script finishes
if price_pairs_for_ipc:
calculate_and_print_ipc(price_pairs_for_ipc)
if changes_found:
logging.info("Script finished. Price changes were detected and logged.")
print("SCRIPT_RESULT=changes_detected")
else:
logging.info("Script finished. No price changes detected.")
print("SCRIPT_RESULT=no_changes")
logging.info(f"Database located at: {os.path.abspath(DB_PATH)}")
logging.info(f"Price change log located at: {os.path.abspath(PRICE_LOG_PATH)}")
Subscribe to my newsletter
Read articles from Max Comperatore directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
