How I built a small Python tool to see what Google SERPs show me in different cities.
Part of international SEO is checking how the same keyword performs across different markets. The results can look very different depending on where the search originates: a query that surfaces B2B guides in New York might return local pages or a different mix of publishers in Singapore.
Those differences are useful content signals. They can show that searchers in one market expect a different angle, need different examples, or trust different sources. But finding content gaps manually is slow, and broad SEO platforms like Semrush and Ahrefs are overkill (and expensive!) for a one-shot content-gap scan.
So I built Localized Content Lens, a lightweight Python tool that compares Google results for the same keyword across cities or countries. It stores SearchApi responses, normalizes the organic results, and produces Markdown reports, an HTML report, and a CSV summary for analysis.
In this article, I’ll walk through how I built the tool using SearchApi to pull localized Google results and turn them into Markdown and HTML reports. Check out the tool’s GitHub repository or changelog.
How the tool works
Localized Content Lens answers a simple question: What does Market A see for this keyword that Market B does not?
SEO specialists can examine the results and decide whether the difference represents a valid content gap or opportunity. These insights allow them to prioritize localization efforts and brief writers on the angle each market expects.
Under the hood, localized search works by sending the same query with different location and language parameters. SearchApi's Google engine accepts values such as location, gl (country), and hl (interface language), so you can request results as if the search came from that city or country. The same keyword is run for each market, and the JSON returns localized organic results for comparison.
In practice, Localized Content Lens takes two inputs (keywords and locations) and pulls Google results through SearchApi. The tool then creates four outputs:
- Raw JSON evidence per keyword-market request
- Markdown reports (one per keyword)
- One HTML report per run
- One CSV summary per run
Repository file tree
The code is split into small modules so each task (requesting, validating, normalizing, comparing, and writing files) can be understood and tested on its own.
localized-content-lens/ ├── compare_markets.py # Entry point ├── content_lens/ # Core modules (see details in the table below) │ ├── api.py │ ├── cli.py │ ├── config.py │ ├── inputs.py │ ├── analysis.py │ ├── outputs.py │ ├── render.py │ ├── html_report.py │ ├── workflow.py │ └── templates/report.html ├── tests/ # Offline unit tests ├── keywords.txt # Keywords to research ├── markets.json # Markets to compare ├── requirements.txt ├── .env.example ├── .gitignore └── market_comparison/ # Output generated at runtime └── YYYYMMDD-HHMMSS/ ├── raw/ ├── reports/ ├── report.html ├── results.csv └── run_manifest.json Pipeline overview
Here's the full pipeline from start to finish:
keywords.txt + markets.json | v Python reads inputs | v SearchApi HTTP requests | v complete raw JSON preserved | v Python normalizes results | v compare domains and rankings | +--------------+-------+--------+ | | | v v v Markdown report HTML report CSV summary Each stage maps to a module and a set of functions:
| Step | Module | Key functions | Output |
|---|---|---|---|
| Load & validate inputs | config.py, inputs.py | load_keywords(), load_markets() | keyword list, market list |
| Collect SERP data | api.py, workflow.py | search_api(), collect_keyword_data() | raw JSON per keyword-market |
| Normalize results | analysis.py | get_items(), normalize_result(), get_domain() | five-field records |
| Compare domains | analysis.py | compare_domains() | domain-presence views |
| Write reports & CSV | outputs.py, html_report.py | make_report(), build_csv_rows(), write_csv(), write_html_report() | Markdown, HTML, CSV |
| Record run | outputs.py | write_run_manifest() | run_manifest.json |
The tutorial is split into two parts: Part 1 collects Google results and normalizes them; Part 2 compares domain presence across markets and turns the data into different output formats.
But before we start, let's set up the environment.
Prerequisites
- Python 3.8 or later
- A SearchApi account and API key (free credits are available when you sign up)
- A local copy of the repository with the virtual environment activated and dependencies installed. (See the repository README for the detailed instructions.)
- A
.envfile containing yourSEARCHAPI_KEY
Part 1: Building the SearchApi data-collection workflow
In this part, I'll collect Google results through SearchApi, store the complete responses, and normalize them (extract and clean the fields into a consistent format). The output from this part is a set of raw JSON files and a cleaned dataset.
1. Configure keyword and location inputs
The first step prepares the two inputs: the keywords to research and the markets to compare them with. I keep both in separate files outside the Python code, so I can add or change them without touching the script.
Firstly, keywords.txt contains one search term per line:
best CRM for small business AI for sales teams account-based marketing software B2B content marketing strategy customer onboarding best practices Second is markets.json which contains a list of market objects. Each has four fields:
-
name: a readable label used in reports and file names -
location: the physical search location sent to SearchApi -
gl: Google's country parameter (See the full list) -
hl: Google's interface-language parameter (see the full list)
For example, I want to compare the search results between New York, United States and Singapore, Singapore:
[ { "name": "US", "location": "New York, New York, United States", "gl": "us", "hl": "en" }, { "name": "SG", "location": "Singapore, Singapore", "gl": "sg", "hl": "en" } ] Before sending anything to SearchApi, the tool validates the inputs so a malformed file does not consume credits.
-
load_keywords(path)strips whitespace, ignores blank lines and#comments, preserves the original order, and raises an error if no usable keywords remain. -
load_markets(path)parses the JSON and checks that a list has at least two markets. Every object must provide a non-emptyname,location,gl, andhl, and market names must be unique (labels such asUSandusare treated as duplicates).
In the latest update (version 1.1), the tool also ships with a small CLI for managing them from the terminal:
-
python3 compare_markets.py initcreates starterkeywords.txt,markets.json, and.envfiles without overwriting anything that already exists. -
python3 compare_markets.py add-keyword "best CRM for small business"appends a keyword, skipping case-insensitive duplicates. -
python3 compare_markets.py add-market --preset sgappends a market, either from a built-in preset or with--name,--location,--gl, and--hlflags. -
python3 compare_markets.py showprints the current keywords, markets, and how many API requests the next run would spend.
2. Request and preserve localized Google results
Before the requests begin, load_api_key() checks that a SearchApi key is configured. Then collect_keyword_data() loops through the markets and calls search_api() once for each keyword-market pair. In this example, five keywords across two markets add up to 10 requests per run.
That said, only successful searches with an HTTP 200 response use credits; failed HTTP requests are not charged. In this tool, SearchApiError covers timeouts, connection errors, unsuccessful HTTP responses, and invalid JSON responses.
A simple request function looks like this:
def search_api(engine, query, api_key, location, gl, hl): response = requests.get( "https://www.searchapi.io/api/v1/search", params={ "engine": engine, "q": query, "api_key": api_key, "location": location, "gl": gl, "hl": hl, }, timeout=30, ) response.raise_for_status() return response.json() For a successful request, the tool saves the raw JSON as an evidence trail:
market_comparison/ └── YYYYMMDD-HHMMSS/ └── raw/ └── best_crm_for_small_business/ ├── us.json └── sg.json Here, the same keyword produces two raw files: one for the US market and one for Singapore.
3. Normalize organic results into comparable fields
A raw SearchApi response contains search metadata, parameters, organic results, and other SERP features. This step selects only the organic results and converts each one into the consistent five-field structure. So records from different markets are easier to compare and export.
The normalization flow uses these functions:
-
get_items(data)reads and returns the response's organic results. If that field is missing or has an incorrect shape, it returns an empty list. -
normalize_result(item)converts one organic result intoposition,title,link,snippet, anddomain. -
first_value(item, *keys)checks a list of possible field names in order and returns the first one containing data. For example, it usestitlewhen available and falls back tonamewhentitleis missing. -
normalize_domain(domain)trims whitespace, converts the domain to lowercase, removes a trailing dot, and strips a leading 'www.' -
get_domain(link)extracts the hostname from the result URL and normalizes it to 'example.com.'
After normalizing, one organic result from the Singapore response is as follows:
{ "position": 2, "title": "Best CRM for Small Business in 2026", "link": "https://slack.com/blog/crm/best-crm-for-small-business", "snippet": "Several tools with free tiers are useful for small businesses, such as Salesforce Starter, HubSpot CRM, and Zoho CRM.", "domain": "slack.com" } At this point, each successful response is preserved as raw JSON, and its organic results now have a consistent structure. Let's move to the next part to build the comparison reports.
Part 2: Turning SERP data into research outputs
The second part uses that standardized data to compare domain presence and turns it into readable files for review.
4. Compare domains across locations
This step compares which domains appear across the markets. compare_domains(results_by_market) builds four views of domain presence:
-
domains_by_market: the unique normalized domains found in each market -
presence_by_domain: the markets in which each domain appeared -
unique_by_market: domains that appeared in exactly one captured market -
shared_by_all: domains that appeared in every captured market
This comparison tracks domain presence, not changes in ranking position. For example, in the New York and Singapore run for best CRM for small business, slack.com appeared in both markets, while fayedigital.com appeared only in New York’s organic results and innowise.com appeared only in Singapore’s.
A domain marked as unique is a research signal. Keep in mind that it may be absent from another market because of ranking volatility, personalization, the limited result depth, or a temporary SERP change.
5. Generate the reports and CSV summary
The raw JSON data preserves all evidence, but it's difficult to review. So in this step, I create three readable outputs:
- one Markdown report per keyword compared;
- one HTML report for the whole run; and
- one combined CSV file for the entire run.
At a high level, the flow looks like this:
csv_rows = [] for keyword in keywords: # raw_results comes from the collection step in Part 1 csv_rows.extend( build_csv_rows( run_id, research_date, keyword, raw_results, locations, ) ) report = make_report( keyword, raw_results, locations, run_id=run_id, research_date=research_date, ) report_path.write_text(report, encoding="utf-8") write_csv(csv_rows, run_dir / "results.csv") # Built once per run from the same raw evidence, no extra API requests write_html_report(run_dir, keywords, ...) make_report() creates a markdown report for one keyword. It includes the run details, market parameters, up to 10 organic results per market, a domain-presence table, shared and unique domains, and blank fields for human analysis.
The HTML report (report.html) presents the same comparison data in another format, with a dropdown to switch between keywords.
build_csv_rows() turns each organic result into one row, tagged with its keyword and market. And write_csv() writes the combined rows to results.csv.
Basically, Markdown helps me investigate one keyword at a time, HTML makes the run easy to browse and present, while CSV gives me a sortable dataset for the full run.
6. Run the workflow
The final step ties the pieces together. I run the tool with python3 compare_markets.py run (plain python3 compare_markets.py works too) using best CRM for small business in New York and Singapore, then verify the chain:
- Loads
keywords.txtandmarkets.json. - Validates the inputs.
- Loops through each keyword and collects data for every market.
- Writes raw JSON, Markdown reports, the HTML report, and CSV rows.
- Writes the run manifest.
This is a simplified version of the loop:
def main(): keywords = load_keywords("keywords.txt") locations = load_markets("markets.json") api_key = load_api_key() run_paths = create_run_paths(OUTPUT_DIR, datetime.now(timezone.utc)) csv_rows = [] failures = [] for keyword in keywords: raw_results = collect_keyword_data( keyword, api_key, locations, raw_dir=run_paths["raw_dir"], failures=failures, ) csv_rows.extend( build_csv_rows( run_paths["run_id"], research_date, keyword, raw_results, locations, ) ) if not failures: report_path = run_paths["reports_dir"] / f"{safe_filename(keyword)}.md" report_path.write_text( make_report( keyword, raw_results, locations, run_id=run_paths["run_id"], research_date=research_date, ), encoding="utf-8", ) # make_report() internally calls compare_domains(raw_results) to build the domain-presence table. write_csv(csv_rows, run_paths["run_dir"] / "results.csv") write_run_manifest(run_paths["run_dir"], ...) Here're the files that the tool generates:
- Raw JSON files contain original SearchApi responses.
-
results.csvmatches the normalized fields. - The Markdown reports' domains, titles, and snippets match the raw JSON and CSV.
-
report.htmlpresents the same comparison data as an interactive, browser-based view.
The project also has write_run_manifest() that records the run ID and ISO start time, search engine, public market fields, the keyword list, attempted and completed requests, generated and skipped reports, the HTML report filename, skip reasons, and failure details. This information can be used to audit the run.
That's how Localized Content Lens is built! The output provides a market-by-market view of what's ranking. This allows me to spot which domains dominate a market, which keywords are likely to need localized content, and where localization efforts should be prioritized first.
Limitations and what's next
Currently, Localized Content Lens captures a snapshot of Google organic results. It does not establish a ranking trend, explain why Google selected a page, or guarantee that publishing a localized article will improve performance. Domain differences are starting points for SEO review.
The per-keyword Markdown and HTML reports are designed for two or three markets and created only if every market request runs successfully. The CSV format can contain results from more locations, but a large comparison still needs a different presentation layer.
The tool's current version covers the core workflow. The next additions I am considering are:
-
google_rank_trackingsupport for up to 100 results per market - Topic clustering across titles and snippets to surface patterns faster
- A lightweight web interface for running comparisons without the command line
- Multi-language keyword input and non-English
hlsettings - Scheduled reruns and change detection for teams that need monitoring
- Comparisons across other Google surfaces, such as News, Images, or Videos
To explore the project, check out the Localized Content Lens repository or test out SearchApi's free credits.



