Automation · Marina Ermina

Messy input in.
Tidy rows out.

Two builds that solve the same problem in different clothes: something unstructured — a sentence spoken in a field, a scattered set of supplier websites — has to end up as clean, dated rows in a spreadsheet, without anyone typing it in. Here is how each one works, and the code underneath.

← Back to marinaermina.com
Automation · AI · Voice

FarmBot — speak it, and it's logged

A voice note from the field becomes a dated row in a Google Sheet, in seconds, with my computer switched off.

The problem

My husband does farm work all day and needed to record what he'd done — task, time spent, date. Writing it up later meant it got forgotten or mis-remembered.

An earlier attempt in Make.com only ran when my laptop was open, so in practice it didn't run at all.

What I built

A Telegram bot living in Google Cloud Run. He sends a voice note or a typed message; Whisper transcribes it; an AI step pulls out task · time spent · date; a row is appended to the sheet and he gets a confirmation back.

He can speak normally — "spent about half an hour feeding the sheep yesterday" resolves to the right date and duration. No format to remember, no app to learn.

FARMBOT — HOW IT WORKS Speak a task on the farm; it's transcribed, understood, and logged — automatically, 24/7. 🎙️ Voice note or text sent in Telegram 🗣️→📝 Speech → text Whisper transcription 🧠 AI extracts date · task · time spent Google Sheet
24/7
runs in Cloud Run
with my computer off
~$2
per month
to operate
2
languages handled
Icelandic & English
1
app he had to learn
(Telegram — he had it)
What lands in the sheet — example rows, in the real column layout
DateTask typeTime spent
2026-08-24Feeding sheep30 min
2026-08-24Fence repair — north field1 h 45 min
2026-08-25Moving hay bales2 h
Illustrative rows — the live sheet holds my husband's actual working record, so it isn't public.
View the code behind it

1 · The webhook, and what happens when Whisper is unavailable

A voice note needs transcription. If that call fails — say the API key runs out of credit — the bot should tell him, not silently swallow the message. Failure has to be visible to the person in the field.

# Telegram calls this endpoint every time he sends a message.
@app.route("/webhook", methods=["POST"])
def webhook():
    # VOICE needs OpenAI Whisper. If that's down (e.g. no credit),
    # we DON'T crash — we tell him to type it instead.
    ...

2 · Turning a spoken sentence into fields

The date is the subtle part. "Yesterday" has to become a real date, and it has to be written YYYY-MM-DD — because that is the format that sorts chronologically when the sheet re-sorts itself.

def extract_fields(text, today_iso):
    """Pull task type, time spent and date out of natural speech.

    The date is returned as a YYYY-MM-DD string — that format sorts
    chronologically when the sheet is sorted.
    """
    ...
    response = openai_client.chat.completions.create(...)

3 · Appending without disturbing what's already there

A logbook people trust is one that never rewrites itself. New rows are added and the sheet is re-sorted by date, but existing rows are never edited or moved.

# Existing rows are never edited, moved, or re-sorted.
gc = gspread.authorize(credentials)
Why there's no "try it live" link: the bot is tied to one private Telegram chat and one Google Sheet holding real working hours, and the repository carries service-account keys. Publishing it would mean publishing those. The diagram and the code excerpts are the honest substitute — happy to walk through the running system on a call.
Automation · Python · Research

Market monitoring — who else sells this, and for how much

Ten searches in English and Icelandic, read and sorted into a single comparable sheet.

The problem

Before positioning a compostable-cup line, I needed to know who already supplied eco cups in Iceland, whether they printed logos, and what they charged.

Doing that by hand means the same afternoon of searching every time you want a refresh — so in practice it gets done once and goes stale.

What I built

A Python script that runs ten searches — five in English, five in Icelandic — de-duplicates by URL, opens each result, reads its meta description, and classifies whether the page mentions pricing, logo printing, and whether the supplier is Icelandic or importing.

The result is written to a Google Sheet through a service account, with a frozen, formatted header row, ready to re-run whenever the picture needs refreshing.

MARKET MONITORING — HOW IT WORKS Ten searches in two languages, read and sorted into one sheet. 🔎 10 search queries English + Íslenska 🌐 Search & de-duplicate one row per company 📄 Read each site price · logo printing · region Google Sheet
10
search queries
run per pass
2
languages searched
English & Icelandic
7
columns produced
per supplier
1
command to refresh
the whole picture
What lands in the sheet — the real column layout, with illustrative rows
Company NameWebsiteDescriptionPrice / Pricing InfoLogo PrintingCountry / RegionLast Updated
Example Umbúðir ehf.example.isDisposable packaging supplier — cups, lids, napkinsMentioned on site — check websiteYesIceland / Nordic2026-08-26
Nordic Eco Supplyexample.comCompostable cups, PLA-lined, wholesale quantitiesContact for pricingYesInternational (ships to Iceland)2026-08-26
Green Cup Co.example.co.ukCustom printed paper cups for cafésMentioned on site — check websiteNot specifiedInternational (ships to Iceland)2026-08-26
Company names here are placeholders. The header row, the classifications and the date stamp are exactly what the script writes.
View the code behind it

1 · Searching in both languages

This is the part that matters most and is easiest to skip. An Icelandic supplier describes itself in Icelandic — search only in English and you miss the local market, which is exactly the market in question.

SEARCH_QUERIES = [
    "biodegradable paper cups distributor Iceland",
    "compostable paper cups supplier Iceland",
    "eco-friendly disposable cups Iceland",
    "sustainable packaging cups Iceland supplier",
    "environmentally friendly paper cups Iceland buy",
    "umhverfisvænar pappírsbikara Ísland",
    "lífrænar einnota bíkara Ísland dreifingaraðili",
    "pappírsbikar lógó prentun Ísland",
    "custom printed paper cups Iceland supplier",
    "wholesale paper cups Iceland",
]

2 · One row per company, not one per search hit

Ten overlapping queries return the same supplier repeatedly. De-duplicating by URL as results arrive is what makes the output a comparison table rather than a pile of links. The pause between searches is courtesy to the search engine.

def search_distributors():
    seen_urls = set()
    results   = []

    with DDGS() as ddgs:
        for query in SEARCH_QUERIES:
            hits = ddgs.text(query, max_results=6)
            for hit in hits:
                url = hit.get("href", "")
                if url and url not in seen_urls:
                    seen_urls.add(url)
                    results.append(hit)
            time.sleep(1.5)

3 · Reading each site and classifying it

A search snippet is often marketing filler. Opening the page and reading its meta description gives a truer one-line summary, which then gets scanned for pricing and logo-printing signals so the columns are comparable across suppliers.

PRICE_KEYWORDS = ["price", "cost", "isk", "kr.",
                  "per unit", "per case", ...]
LOGO_KEYWORDS  = ["logo", "print", "custom print",
                  "branded", "branding", ...]

def scrape_meta_description(url: str) -> str:
    resp = requests.get(url, headers={...}, timeout=10)
    if resp.status_code == 200:
        soup = BeautifulSoup(resp.text, "html.parser")
        tag  = soup.find("meta", attrs={"name": "description"})

4 · Keeping the key out of the repository

Writing to a Google Sheet needs a service-account key. It's resolved at runtime from a folder that's git-ignored, never hard-coded — so the script can be shown to anyone without showing them the credentials.

_CREDENTIAL_CANDIDATES = [
    os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
    os.path.join(PROJECT_DIR, "secrets", CREDENTIALS_FILENAME),
    ...
]
An honest note on scope: the price and logo-printing columns are keyword classifications of what a page says, not verified quotes — they tell you where to look, and I check the shortlist by hand. Calling that a price comparison would be overstating it.