Monitoring Foreign-Language News with Local Models

Hardware news breaks in Chinese, Japanese, and Korean while North America is asleep, and by the time it reaches English it is twelve hours old. This is a pipeline that collects it overnight and emails a graded digest by morning, built around one inversion: translation is the expensive step, so score first and translate only what survives. On one real run, 103 of 143 candidates never touched the GPU.

7 steps 22 min read 2026-08-20
Tools UsedSee full toolkit below →
Ollama
Local model runtime; translation and drafting on hardware you own, no API bill
Scrapling
Local fetch backend for sources whose feeds are behind a WAF
SQLite
The 30-day dedupe window and run log; one file, no server
Resend
Delivers the morning digest over a plain HTTP API

If you follow PC hardware, a meaningful share of the news breaks first in Chinese, Japanese, or Korean. Board partners leak specs to Taiwanese outlets, mainland sites get the SKU listings early, Japanese retail blogs post pricing before anyone else notices. By the time a story reaches English-language coverage it is often twelve hours old and someone else has already written the definitive version.

The obvious fix is to read those sources directly, which runs into two problems immediately. They publish while you’re asleep, and they publish in languages you may not read. The obvious fix for that is to throw everything at a translation API, which is where most attempts quietly die: it’s slow, it costs real money at volume, and roughly nine out of ten items are phone launches, EV news, or AI-industry gossip you don’t care about. You end up paying to translate junk.

This is a pipeline that solves it the other way around. It collects overnight from a set of Asian outlets, filters aggressively before spending anything, scores what’s left against a beat, translates only the survivors on a local model, drafts outlines for the best few, and emails a digest. The stages are:

fetch -> filter-age -> dedup -> filter-pre -> score-raw -> translate
      -> filter-post -> score -> email-floor -> draft -> email

The one idea worth taking away, if you take nothing else: translation is the expensive step, so it goes near the end, not the beginning. On a real run this meant 165 items fetched, 143 surviving the first filters, and only 40 of them ever reaching the model. The other 103 were discarded for free.

Everything runs on hardware I already own, using Ollama, which means no per-token cost, no rate limits, and nothing leaving the network. That last part matters less for public news than it would for private documents, but the economics matter a lot: this pipeline can afford to be wasteful in ways an API-billed one cannot.

Important: This was a personal experiment, and it runs end to end. Two logged runs completed with a digest of eight items each, and the sample output carries real scores between 65 and 80 with drafted outlines attached. What is not done is stated in Step 7: the cron schedule is written but not active, and sender-domain verification is still pending, so delivery is currently limited to the account holder.

Step 1

The Shape of the Answer

Before any code, it’s worth being precise about what makes this hard, because each property drives a design decision.

The clock. Asian outlets publish during their working day, which is overnight in North America. This is a batch problem, not a streaming one. You do not need a live feed; you need three runs while you sleep and a digest at breakfast. That single realization removes an enormous amount of complexity, because nothing has to be fast.

The language. Titles and leads arrive in Chinese, Japanese, and Korean. Translation is the only stage in this pipeline with a real per-item cost, whether that cost is money on an API or seconds of GPU time locally.

The ratio. A broad set of tech sources produces hundreds of items a night, and for a narrow beat, single digits are worth reading. This is a filtering problem wearing a translation problem’s clothing.

Put those together and the architecture follows. Batch, so nothing needs to be fast. Filter hard and early, because most items are junk. Translate late, because it’s the expensive step. Score before translating, because you cannot afford to translate everything you might want to score.

Local versus API models is the other early decision. A hosted model is better and easier, and for a low-volume job it’s cheap. It is also metered, which changes how you design: if every translation costs money you become reluctant to over-fetch, and reluctance is the wrong instinct for a collector. Running locally means the marginal cost of an extra source is zero, and you tune for quality instead of invoice. The tradeoff is real: setup, a machine that stays on, and models that are good rather than excellent. For translating hardware headlines, good is enough.

Step 2

Declaring Sources

Every source lives in one YAML file, and adding or removing one never touches code:

sources:
  - id: ithome
    name: ITHome
    type: rss
    url: https://www.ithome.com/rss/
    tier: a
    lang: cn
    polling: each
    enabled: true

  - id: expreview-news
    name: Expreview news index
    type: scrape
    fetch_via: scrapling
    url: https://www.expreview.com/portal.php?mod=indexNew&contentType=news&page=1
    selector: 'a[href*="expreview.com/"][href$=".html"]'
    tier: a
    lang: cn
    polling: each
    enabled: true

The fields earn their place. type selects a fetcher: a normal feed, a scrape, or a syndication endpoint. fetch_via picks the backend when scraping. lang tells the translate stage what it’s dealing with and lets English and German sources skip translation entirely. tier feeds the scoring rubric, so a first-party outlet outranks an aggregator on identical text. And enabled is the pruning mechanism: when a source breaks, you set it to false with a comment explaining why, and the entry stays as documentation rather than disappearing into git history.

The working set is thirteen sources: mainland Chinese outlets, Taiwanese press, Japanese retail and news blogs, Korean hardware forums, and one English benchmark database. Breadth matters more than you’d think, because the same leak surfacing on four sites is itself a signal, which Step 4 exploits.

Three fetch backends, chosen per source. Most feeds are fine with plain HTTP and a feed parser. Some sit behind a WAF that rejects anything that looks automated, and those go through Scrapling locally. A cloud option (Firecrawl) covers anything Scrapling can’t handle. The abstraction is what matters: the fetcher is a per-source declaration, so a site changing its posture is a one-line config edit rather than a code change.

Step 3

When RSS Lies

This is where the actual time goes, and it is worth documenting properly because “the feed doesn’t work” turns out to be four distinct problems that look identical from the outside.

The feed exists and is broken. One mainland outlet’s site-wide RSS returns a 302 to an error page. Not a 404, not a block, just a redirect into nothing. The fix was per-category feeds, which are alive and well at a different subdomain, and are arguably better since they let the beat filter start at the source.

The feed exists and is defended. Another outlet’s feed sits behind a WAF that returns a challenge instead of XML. The feed itself is fine; you simply cannot reach it with a plain HTTP client. The fix was to stop trying and scrape the news index page instead, with a CSS selector pulling article links, routed through Scrapling.

The block is cleared and it still doesn’t work. One forum was the instructive case. Scrapling gets past Cloudflare cleanly, the page arrives, and the selector returns zero items. That is not a blocking problem at all, it’s a page-structure problem: the forum runs software whose markup doesn’t match the assumed selector. If you conflate the two, you spend an evening tuning fetch behavior that already works. Always confirm you got the page before blaming the fetch.

The endpoint is simply gone. A syndication endpoint that used to return posts now returns nothing parseable. Self-hosting RSSHub is the fallback, but that path needs an auth token that is fragile and 403s. That source stays disabled.

A fifth, milder case: one Japanese source publishes news and daily sales posts through the same feed, so it needs a URL filter dropping specific path prefixes before anything else looks at it.

The lesson is procedural rather than technical. When a source fails, establish which failure it is before reaching for a tool: did the request succeed, did you get HTML or a challenge page, and did the parser find nodes? Three checks, and they point at three different fixes. Then, if the answer is “this needs more work than it’s worth today,” set enabled: false with a note and move on. A collector with eleven working sources beats one with thirteen sources and two that throw exceptions every night.

Important: Be a good citizen about this. Everything here is public news content, fetched at three batches a night rather than continuously, with a self-identifying User-Agent (something like newsdigest/0.1, naming your tool) rather than a spoofed browser string. That matters: a site operator who wants to block you should be able to identify you and do so, and one who wants to ask you to slow down should be able to find you. Respect robots.txt, keep the request rate low enough that you are not a load, prefer a site’s official feed whenever one works, and store titles and leads rather than republishing full articles. The point of this pipeline is to find stories worth reading at the source, not to mirror anyone’s content.

How AI can help

Diagnosing a broken source is a good pairing task because the evidence is verbose and the pattern-matching is mechanical. Paste the response status, headers, and the first chunk of the body, and ask which of the three failure classes it is: transport, challenge, or parse. It is reliably good at recognizing a WAF interstitial or a JS-challenge page from the HTML alone, which is the distinction that saves the most time. It is also useful for writing the CSS selector once you have real markup, though verify against the actual page rather than trusting the first suggestion, since a selector that matches nothing and a selector that matches everything both look plausible in isolation.

Step 4

Filter Before You Spend

Everything in this step is free, so it all happens before anything expensive.

Age. Anything with a timestamp older than a cutoff (36 hours here) gets dropped. Undated items from scraped index pages pass through rather than being discarded, since absence of a date is not evidence of staleness.

Deduplication, in three layers. A canonicalized URL hash catches the identical link. A normalized title hash (lowercased, punctuation stripped, whitespace collapsed) catches the same story posted at two URLs. Then fuzzy matching with rapidfuzz’s token_set_ratio at a threshold of 85 catches the same story with a reworded headline.

Two details make this work better than it sounds. The comparison runs against a 30-day SQLite window of items already sent, so a story that resurfaces a week later doesn’t come back. And it also runs within the current batch, which matters enormously with thirteen sources on one beat: a genuine leak gets posted by four outlets inside an hour, and without in-batch dedupe your eight-item digest is the same story four times.

A pre-translate content filter. This is the subtle one. Some junk is only recognizable in the source language, because a digest post is titled 早报 or 汇总 and there is no English text yet to match against. So the filter carries source-language patterns for roundups, daily-briefing posts, and tutorial content, and runs before translation. Anything it catches is junk you never paid to translate.

On a real run these stages took 165 fetched items down to 143 before anything expensive happened.

Step 5

Score Before You Translate

This is the centerpiece, and it rests on one trick that sounds obvious once stated and is easy to miss: make the keyword lists bilingual, and the scoring rubric works on untranslated text.

The beat here is CPUs, GPUs, memory, and storage, plus motherboards and chipsets. So the beat vocabulary carries both sides:

NARROW_BEAT = {
    "ryzen", "epyc", "threadripper", "xeon", "core ultra",
    "zen 5", "zen 6", "x3d",
    "锐龙", "至强",
    "rtx", "radeon rx", "geforce", "intel arc",
    "显卡", "显存",
    "ddr5", "gddr7", "hbm",
    "内存条", "内存模组",
    "nvme", "nand", "ssd controller",
    "phison", "silicon motion", "maxio", "kioxia",
    "固态硬盘", "主控", "闪存",
    "x870", "z890", "am5", "lga1851",
    "主板", "芯片组",
}

锐龙 is Ryzen. 显卡 is graphics card. 固态硬盘 is SSD, 主控 is controller, 闪存 is flash. Part numbers and vendor names are already language-neutral, which does a lot of the work for free. Put those together and a Chinese headline about an SSD controller scores correctly without a single token of translation.

The rubric is a hundred points across seven weighted components:

Component Weight What it rewards
Beat fit 25 Matches the vocabulary above
Specificity 20 Named parts and models over vague category talk
Source tier 15 First-party outlets over aggregators
Numbers 10 Concrete figures, clocks, capacities, prices
Recency 10 Fresher is better
Leak bias 10 Unannounced and unreleased over review coverage
Uniqueness 10 Not already circulating

Before scoring, a hard-exclude list drops entire categories outright: phones, EVs, consoles, and AI-industry personality news. These are legitimate tech stories that are simply not this beat, and dropping them at zero cost is better than ranking them low. Regex patterns catch digest posts and tutorial or explainer content, which are formats rather than topics and never belong in a news digest.

Then the gate: only the top K by score are translated, K being 40 here. On the run cited earlier, 143 items were scored and 40 went to the model. 103 items, 72% of the batch, were sorted and discarded without the GPU ever waking up.

Tip: Use a deterministic rubric rather than asking a model to score things. It runs instantly, costs nothing, and returns the same answer for the same input, which means when a bad item ranks high you can read the score breakdown and see exactly which component misfired, then fix that line. An LLM scorer is slower, consumes the resource you are trying to conserve, cannot be diffed, and will occasionally rate the same headline 40 and 70 on consecutive runs. Save the model for the job only a model can do.

How AI can help

Building the bilingual vocabulary is genuinely the right task to delegate, and it is the one place a language model has an unfair advantage over you. Give it your English beat terms and ask for the terms the target-language press actually uses, not dictionary translations, and ask it to explain each one so you can sanity-check. The distinction matters: the literal translation of "graphics card" and the term Chinese tech outlets actually print in headlines are not always the same string, and the one you want is the one they print. It is equally good at the hard-exclude list, since "name the phone brands whose launch coverage will flood a hardware feed" is exactly the kind of broad recall it does well and you do poorly at midnight.

Step 6

The Local Model Stages

Two models, both local, doing two different jobs.

Translation runs a mid-size multilingual model, and the prompt does most of the work:

Translate the following hardware-news headline and lead from {lang} to English.
Preserve product names, part numbers, units (MB/s, GHz, V, W, $), percentages,
and benchmark scores exactly. Do not paraphrase. Do not add commentary.

Return ONLY a JSON object: {"title": "...", "lead": "..."}

That instruction to preserve part numbers and units is not decoration. For hardware news the numbers are the story, and a translator doing its normal job of producing fluent readable prose will happily round “7,450 MB/s” into “around 7.5 GB/s” or turn a specific SKU into a generic product description. Fluency is the enemy here. You want a literal, boring translation that keeps every figure intact.

Two implementation notes worth stealing. Ask for JSON and request it via the API’s JSON mode, but retry once without JSON mode if parsing fails, because some models ignore the flag and return prose that happens to contain the answer. And skip translation entirely for languages you can already read, via a passthrough list, rather than paying to translate English into English.

A second filter runs after translation, and it catches a different class of junk than the first one. Once text is in English, an entirely new set of exclusions becomes matchable: AI-executive news, phone launches, and consumer-electronics stories that carried no matching keyword in their original language. Filtering twice, once on each side of the translation, catches things neither pass would alone.

Then re-score. The first scoring pass ran on original-language text and did well, but now there is English text contributing too, and the beat vocabulary matches more of it. Items shuffle, which is the point.

Drafting uses a larger model on only the top handful, generating a skeleton outline for each story against a voice guide describing tone and structure. This is the one stage where a bigger, slower model is worth it, precisely because it runs on three items rather than forty. The output is an outline to work from, not copy to publish.

Step 7

Delivery, and What’s Still Open

Delivery is a plain HTTP POST to Resend, rendering the digest as markdown and converting to HTML. The email has two sections: fully drafted items with their outlines, and a watchlist of everything else that cleared the bar, each with its score and source.

Two separate floors, and the distinction is deliberate. One score threshold governs inclusion in the email at all, a higher one governs whether an item gets a drafted outline. That lets a quiet night still produce a short watchlist without wasting model time drafting weak stories. And if nothing clears the lower floor, the pipeline sends nothing and logs why. A digest that arrives every morning regardless of whether anything happened trains you to ignore it; silence should mean silence.

The runtime knobs all live in environment variables, so tuning never means editing code: digest size, drafting count, both score floors, the age cutoff, the translate cap, the dedupe window, and the fuzzy-match threshold.

Where this actually stands. It runs end to end. Two logged runs completed and sent digests of eight items each, and the dry-run dump holds real graded output with scores from 65 to 80 and outlines attached. The funnel numbers quoted throughout this guide are measurements from those runs, not estimates.

What is not finished: the cron schedule is written but not installed, so runs are currently manual. And the sender domain is not verified, which on Resend means delivery is limited to the account holder’s own address; sending to anyone else needs the domain’s SPF and DKIM records in place first. Both are small, and both are the kind of thing that is easy to describe as done when it isn’t.

Deliberately out of scope: full-article extraction (titles and leads are enough to decide whether to go read the original, and storing more raises questions this project has no reason to raise), images, any kind of web UI, and the RSSHub path for social sources, which was tried and shelved.

What You Spent

Almost nothing recurring, which is the entire argument for the local-model approach.

The comparison that matters is against doing the same thing on a hosted translation or LLM API. Translating a few dozen items a night is not expensive in absolute terms, but it is metered, and metering changes behavior. Once every fetch has a downstream cost you start trimming sources, raising thresholds, and translating less, which is exactly backwards for a tool whose job is to not miss things. Running locally, adding a fourteenth source costs nothing, and the scoring gate exists to save time rather than money.

The honest counterargument: a hosted frontier model translates better than a local mid-size one, and for prose where nuance matters that gap is real. For hardware headlines, where the requirement is preserving part numbers and not paraphrasing figures, the local model is entirely sufficient, and the prompt matters more than the model does.

Toolkit Reference

The components that appear across this guide, and the concrete spots where an AI assistant earns its keep.

Pipeline Stack

Ollama
Local model runtime. Two models: a mid-size multilingual one for translation, a larger one for drafting the top few items.
Scrapling
Local fetch backend for sources whose feeds sit behind a WAF. Selected per source in config, not in code.
Firecrawl
Cloud fetch backend, the fallback when the local one cannot get a page. Optional; only needed if a source declares it.
feedparser + selectolax
Feed parsing and fast HTML parsing for the scrape path.
RapidFuzz
Fuzzy title matching, the third dedupe layer that catches the same story under a reworded headline.
SQLite
The 30-day sent-items window and the run log. One file, no server, trivially inspectable with the CLI.
Resend
Digest delivery over a plain HTTP API. Sending to anyone but the account holder requires domain verification with SPF and DKIM.
RSSHub
Generates feeds for sites that do not offer them. Tried for social sources, shelved over fragile auth.

Where AI Earns Its Keep

Building bilingual keyword lists
The single best use here. Ask for the terms the target-language press actually prints in headlines, not dictionary translations, with an explanation of each so you can verify.
Diagnosing a dead source
Paste status, headers, and body. It reliably distinguishes a WAF interstitial from a JS challenge from a genuine parse failure, which is the distinction that saves the most time.
Writing selectors against real markup
Fine, but verify against the live page. A selector matching nothing and one matching everything look equally plausible until you run them.
Translation prompt tuning
Ask it to critique a translation prompt for a domain where numbers matter. Fluency is the failure mode, and it will suggest the constraints that suppress it.
Rubric review, not rubric execution
Have it critique your weights and hard-exclude lists against sample output. Do not have it score items; a deterministic rubric is faster, free, and diffable.