Every commercial website in Germany (and Austria and Switzerland) has to publish an Impressum: the legal notice with the company name, postal address, contact details, commercial register entry and VAT ID. For B2B work that's useful structured data. You can verify a supplier, complete a CRM record with the registered address and register number, or build a market overview of the companies in a niche.
This post covers:
- A small Python script (standard library only) that finds a site's Impressum and pulls out VAT ID, register number, court, phone and email.
- Why the regex approach breaks on real pages, plus what you may and may not do with the data under GDPR and German competition law.
- A hosted Impressum scraper that turns a domain list into a CSV, called from Python and Node.js.
Disclosure: I built the Impressum Scraper Actor used in part 3. Parts 1 and 2 don't need it.
1. A minimal extractor (Python standard library)
Two steps: find the link labelled Impressum / Imprint / Legal notice on the homepage, then run a few regular expressions over the visible text of that page.
import re import sys import urllib.request from html import unescape from html.parser import HTMLParser from urllib.parse import urljoin UA = {"User-Agent": "Mozilla/5.0 (impressum lookup script)"} class Page(HTMLParser): """Collects <a href> links with their text, and the visible text.""" def __init__(self): super().__init__() self.links, self.text, self._href, self._skip = [], [], None, 0 def handle_starttag(self, tag, attrs): if tag in ("script", "style"): self._skip += 1 if tag == "a": self._href, self._label = dict(attrs).get("href"), "" if tag in ("br", "p", "div", "li", "tr", "h1", "h2", "h3"): self.text.append("\n") def handle_endtag(self, tag): if tag in ("script", "style"): self._skip -= 1 if tag == "a" and self._href: self.links.append((self._href, self._label.strip())) self._href = None def handle_data(self, data): if self._skip: return self.text.append(data) if self._href is not None: self._label += data def fetch(url): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=20) as r: html = r.read().decode(r.headers.get_content_charset() or "utf-8", "replace") return r.url, html def parse(html): p = Page() p.feed(html) return p def find_impressum(domain): home_url, html = fetch(f"https://{domain}") for href, label in parse(html).links: if re.search(r"impressum|imprint|legal[-_ ]?notice", f"{href} {label}", re.I): return urljoin(home_url, href) return None PATTERNS = { "vatId": r"\b(DE ?\d{3} ?\d{3} ?\d{3})\b", "register": r"\b(HR[AB] ?\d{2,6}(?: ?[A-Z]{1,2})?)\b", "registerCourt": r"(Amtsgericht [A-ZÄÖÜ][\wäöüß.\- ]{2,30}?)(?=[,;\n]|$| HR)", "phone": r"(?:Tel(?:efon)?\.?:?|Phone:?)\s*(\+?[\d ()/\-]{7,})", "email": r"([\w.+-]+@[\w-]+(?:\.[\w-]+)+)", } def extract(domain): url = find_impressum(domain) if not url: return {"domain": domain, "found": False} _, html = fetch(url) text = unescape(re.sub(r"[ \t\xa0]+", " ", "".join(parse(html).text))) row = {"domain": domain, "found": True, "impressumUrl": url} for key, rx in PATTERNS.items(): m = re.search(rx, text, re.I if key in ("phone", "email") else 0) row[key] = m.group(1).strip() if m else None return row if __name__ == "__main__": for d in sys.argv[1:] or ["tchibo.de"]: print(extract(d)) Output (03.11.2026):
$ python impressum_stdlib.py tchibo.de weleda.de einhell.de {'domain': 'tchibo.de', 'found': True, 'impressumUrl': 'https://www.tchibo.de:443/c/service-impressum', 'vatId': 'DE 811164447', 'register': 'HRB 43618', 'registerCourt': 'Amtsgericht Hamburg', 'phone': '+49 (0) 40 63 87 - 0', 'email': 'service@tchibo.de'} {'domain': 'weleda.de', 'found': True, 'impressumUrl': 'https://www.weleda.de/footer/rechtliches/impressum', 'vatId': 'DE 146 751 499', 'register': 'HRB 700061', 'registerCourt': 'Amtsgericht Ulm', 'phone': '+49 7171 919 0', 'email': 'dialog@weleda.de'} {'domain': 'einhell.de', 'found': True, 'impressumUrl': 'https://www.einhell.de/impressum/', 'vatId': 'DE 128 943 763', 'register': None, 'registerCourt': None, 'phone': '+49 9951 942-0', 'email': None} Two out of three look good. The third shows why this gets harder than it looks.
2. Where it breaks, and the legal part
Technical pitfalls
- Formatting varies a lot. Einhell writes
Registergericht Landshut HR B 2171, with a space inside "HR B" and no "Amtsgericht", so both regexes miss it. Others writeHRA 260 169(with a space inside the number),Handelsregister: Amtsgericht Stuttgart, or the EnglishCourt of Registration: Local Court Ulm. VAT IDs come asDE 811164447,DE 146 751 499orDE128943763, so normalise them by removing spaces. - Emails are often obfuscated (
info [at] firma.de, built with JavaScript, or shown as an image). einhell.de's email was simply not in the plain text my script parsed. - Find the right page. The link might say Impressum, Imprint, Legal notice, Anbieterkennzeichnung or Rechtliches, sit in a footer loaded by JavaScript, or live on a different domain (
/c/service-impressum,/footer/rechtliches/impressum). Some sites block plain HTTP clients altogether. - Group sites list several companies. The first
GmbHon the page isn't always the website operator. It might be the parent company, the hosting provider or the photo credits. - Validate what matters. If a VAT ID is important (for example for invoicing), check it against the EU's official VIES service instead of trusting a scraped string.
Legal: what the data may be used for
This is not legal advice. It's a summary of the points that matter in practice. Check your own case with your data protection officer or a lawyer.
- It's public, but that doesn't mean "free for any use". The Impressum is required by § 5 DDG (formerly § 5 TMG) so that people can identify and contact the provider. Company data (company name, registered address, register number, VAT ID) isn't personal data. Names of managing directors, sole traders' names and personal email addresses like
max.mustermann@…are personal data, so the GDPR applies to them. - Collect only what you need (Art. 5(1)(c) GDPR). For company-level enrichment you don't need person names. The Actor in part 3 has
includePersonNames: falsefor exactly this, and the examples below use it. - You need a legal basis. For B2B research this is usually legitimate interest (Art. 6(1)(f) GDPR). Document your balancing test, and if you store personal data, plan for the information duty under Art. 14 GDPR, objections (Art. 21) and deletion.
- Cold emails are the big trap. Under § 7(2) UWG, advertising by email requires the recipient's prior express consent, even when the recipient is a business. An email address in the Impressum is not consent. Cold calls to businesses need at least presumed consent. Scraping a list and mass-mailing it is exactly what gets you warning letters (Abmahnungen).
- Be polite technically. Respect robots.txt and the site's terms, keep concurrency low, and don't re-crawl the same sites every day.
Good uses: checking suppliers or customers (KYC), filling in and correcting existing CRM records, verifying VAT IDs and register entries, market research at company level, and preparing contacts you then approach through a lawful channel.
3. Hosted option: Impressum Scraper on Apify
The Impressum Scraper finds the Impressum page (German and English link labels such as Impressum, Imprint, Anbieterkennzeichnung or Rechtliche Hinweise, then common paths like /impressum as a fallback) and extracts company name, legal form, street, postal code, city, country, phone, fax, emails, VAT ID, register court, type and number. Optionally it also extracts managing directors. It handles the variants above (HR B 2171, HRA 260 169, English register wording) and works with plain HTTP, no browser.
Set APIFY_TOKEN from a free Apify account (Console → Settings → API & Integrations).
Python (pip install apify-client)
"""Turn a list of German/Austrian/Swiss company domains into a CSV of company-level contact data. Actor: https://apify.com/paperlinenstudio/impressum-scraper Usage: APIFY_TOKEN=... python impressum_to_csv.py tchibo.de weleda.de Use the data responsibly: GDPR and the German UWG apply (see the README / blog post). """ import csv import os import sys from apify_client import ApifyClient domains = sys.argv[1:] or ["tchibo.de", "weleda.de", "einhell.de"] client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("paperlinenstudio/impressum-scraper").call( run_input={ "websites": domains, "includePersonNames": False, # company-level data only (data minimisation) "maxConcurrency": 5, } ) rows = list(client.dataset(run.default_dataset_id).iterate_items()) fields = ["domain", "companyName", "legalForm", "street", "postalCode", "city", "country", "phone", "email", "vatId", "registerCourt", "registerType", "registerNumber", "impressumUrl"] with open("companies.csv", "w", newline="", encoding="utf-8") as f: w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore") w.writeheader() w.writerows(r for r in rows if r["found"]) for r in rows: if r["found"]: print(f"{r['companyName']:<28} {r['postalCode']} {r['city']:<18} VAT {r['vatId']} {r['registerType']} {r['registerNumber']}") else: print(f"{r['domain']:<28} no Impressum data: {r['error']}") print(f"saved {sum(r['found'] for r in rows)} companies to companies.csv") Output (03.11.2026):
$ python impressum_to_csv.py tchibo.de einhell.de kaercher.com Alfred Kärcher SE & Co. KG 71364 Winnenden VAT DE147223775 HRA 260 169 Tchibo GmbH 22297 Hamburg VAT DE811164447 HRB 43618 Einhell Germany AG 94405 Landau/Isar VAT DE128943763 HRB 2171 saved 3 companies to companies.csv Einhell's register entry, which the regex in part 1 missed, is now found, including the court.
Node.js (npm i apify-client)
// Turn a list of German/Austrian/Swiss company domains into a CSV of company-level contact data. // Usage: APIFY_TOKEN=... node impressum_to_csv.mjs tchibo.de weleda.de import { writeFileSync } from 'node:fs'; import { ApifyClient } from 'apify-client'; const domains = process.argv.slice(2); const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); const run = await client.actor('paperlinenstudio/impressum-scraper').call({ websites: domains.length ? domains : ['tchibo.de', 'weleda.de', 'einhell.de'], includePersonNames: false, // company-level data only (data minimisation) maxConcurrency: 5, }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); const fields = ['domain', 'companyName', 'street', 'postalCode', 'city', 'phone', 'email', 'vatId', 'registerCourt', 'registerType', 'registerNumber']; const esc = (v) => `"${String(v ?? '').replaceAll('"', '""')}"`; const found = items.filter((c) => c.found); writeFileSync('companies.csv', [fields.join(','), ...found.map((c) => fields.map((k) => esc(c[k])).join(','))].join('\n')); for (const c of items) { console.log(c.found ? `${c.companyName} | ${c.postalCode} ${c.city} | VAT ${c.vatId} | ${c.registerType} ${c.registerNumber}` : `${c.domain}: ${c.error}`); } console.log(`saved ${found.length} companies to companies.csv`); $ node impressum_to_csv.mjs weleda.de kaercher.com Alfred Kärcher SE & Co. KG | 71364 Winnenden | VAT DE147223775 | HRA 260 169 Weleda AG | 73525 Schwäbisch Gmünd | VAT DE146751499 | HRB 700061 saved 2 companies to companies.csv $ cat companies.csv domain,companyName,street,postalCode,city,phone,email,vatId,registerCourt,registerType,registerNumber "kaercher.com","Alfred Kärcher SE & Co. KG","Alfred-Kärcher-Strasse 28-40","71364","Winnenden","+49 7195 / 14-0","info@karcher.com","DE147223775","Amtsgericht Stuttgart","HRA","260 169" "weleda.de","Weleda AG","Möhlerstraße 3-5","73525","Schwäbisch Gmünd","+49 7171 919 0","dialog@weleda.de","DE146751499","Amtsgericht Ulm","HRB","700061" A single record (real, from a run on 03.11.2026, with includePersonNames: false):
{ "website": "https://einhell.de", "domain": "einhell.de", "impressumUrl": "https://www.einhell.de/impressum/", "found": true, "companyName": "Einhell Germany AG", "legalForm": "AG", "street": "Wiesenweg 22", "postalCode": "94405", "city": "Landau/Isar", "country": "DE", "phone": "+49 9951 942-0", "fax": "+49 9951 1702", "email": "info@einhell.de", "emails": [ "info@einhell.de" ], "vatId": "DE128943763", "registerCourt": "Amtsgericht Landshut", "registerType": "HRB", "registerNumber": "2171", "managingDirectors": [], "error": null, "scrapedAt": "2026-11-03T13:42:40+00:00" } Inputs you'll probably change
{ "websites": ["firma-a.de", "https://www.firma-b.at", "firma-c.ch"], "includePersonNames": false, "onlyFound": true, "maxConcurrency": 5 } onlyFound: true drops sites where no Impressum was found. If you leave it off, those sites appear with an error field, which is handy for spotting dead domains in an old CRM export.
Wrap-up
- A handful of sites with consistent formatting: the standard-library script in part 1 works. Normalise VAT IDs and expect the register regex to need tuning.
- Hundreds of domains with all the formatting variants, and a CSV/Excel export: Impressum Scraper.
- Whatever tool you use: stick to company-level data where you can, document your legitimate interest, and don't cold-email the addresses you collect.
Feedback and feature requests are welcome, either in the comments or on the Actor's Issues tab.



