Install OneContact 1.0.0 for .NET 10, then validate and format a phone number, check an email address, parse a web address, read a postal address and pull the contacts out of a small web page. Every sample on this page was run on the 1.0.0 packages from NuGet and shows its real output. The inputs are made up: phone numbers from ranges set aside for fiction, and example.com addresses.
Install
The packages target net10.0, so you need the .NET 10 SDK.
dotnet new console -o OneContactStart cd OneContactStart dotnet add package RecursiveAI.OneContact --version 1.0.0 dotnet add package RecursiveAI.OneContact.Extraction --version 1.0.0
Three packages are on NuGet at 1.0.0, all on one version:
RecursiveAI.OneContact: phones, email, web addresses, postal addresses, names and companies. It depends on the .NET base class library only.RecursiveAI.OneContact.Extraction:ContactExtractor, which finds people, organizations and their contact fields in HTML and plain text. It brings in the core and OneHtml 1.6.0.RecursiveAI.OneContact.Phones.Geo: the geocoder, carrier and time-zone mappers for phone numbers.
None of them uses native code, and in the README’s words, “Nothing in the packages opens a socket, resolves a name or downloads a file.”
Version 1.0 has no trained models. In the README’s words: “Version 1.0 runs on rules and markup alone. The trained model packages (RecursiveAI.OneContact.Models.Tier1, .Models and .Models.Accurate) follow in 1.1, after OneNLP 1.0.” Text the rules can’t read is refused with a reason, never guessed.
The samples below are one Program.cs, run top to bottom. It starts with these lines:
using RecursiveAI.OneContact; using RecursiveAI.OneContact.Extraction; using RecursiveAI.OneContact.Extraction.Records;
Validate and format a phone number
A national number needs a region; one written with + and its country code doesn’t. A parsed number has its E.164 form, every format, a number type and a validity check against the numbering plan.
// A national number needs a region; an international one does not. PhoneNumber phone = PhoneNumber.Parse("(202) 555-0145", Region.US); Console.WriteLine(phone.E164); Console.WriteLine(phone.Format(PhoneFormat.International)); Console.WriteLine(phone.Format(PhoneFormat.National)); Console.WriteLine(phone.Format(PhoneFormat.Rfc3966)); Console.WriteLine($"{phone.Type} | valid={phone.IsValid} | region {phone.Region}"); PhoneNumber london = PhoneNumber.Parse("+44 20 7946 0958"); Console.WriteLine($"{london.Format(PhoneFormat.National)} | {london.Type} | valid={london.IsValid} | region {london.Region}"); Console.WriteLine(PhoneNumber.TryParse("2025550145", out _) ? "parsed" : "refused: a national number needs a region"); Console.WriteLine(Contacts.SameKey("(202) 555-0145", "+1 202.555.0145", Region.US));
+12025550145 +1 202-555-0145 (202) 555-0145 tel:+1-202-555-0145 FixedLineOrMobile | valid=True | region US 020 7946 0958 | FixedLine | valid=True | region GB refused: a national number needs a region True
A national number with no region is refused rather than guessed. Contacts.SameKey compares two values by their comparison key, so differently written forms of one number match. The project checks these results against libphonenumber. For parsing, validation, number types, every format, region, possibility and example numbers, across every region and non-geographic code, the README counts mismatches on generated cases against libphonenumber 9.0.43: “0 of 2,819,630 results (141,206 cases, 2 seeds)”. Phone numbers covers the as-you-type formatter, the matcher that finds numbers in text, and short numbers.
Check an email address
EmailAddress.Parse reads one mailbox by RFC 5322 and RFC 6531, with or without a display name, and throws ContactParseException with a reason when the address isn’t one. TryParse does the same without the exception.
foreach (string text in new[] { "Jo Example <Jo.Example+news@Example.com>", "sales@example.com", "jo@example..com" }) { try { EmailAddress email = EmailAddress.Parse(text); Console.WriteLine($"{email.Canonical} | {email.DisplayName} | {email.Flags}"); } catch (ContactParseException e) { Console.WriteLine($"refused: {e.Reason.Id}"); } }
Jo.Example+news@example.com | Jo Example | HasDisplayName sales@example.com | | RoleAccount refused: email.invalid-domain
The canonical form keeps the local part as written and lowercases the domain. RoleAccount marks a role address such as sales@. The empty label in example..com makes the domain invalid. Flags such as FreeProvider, Disposable and PossibleTypo only report: in the docs’ words on typo suggestions, “nothing is ever changed”. Email addresses has every rule, flag and provider key.
Parse a web address
WebAddress follows the WHATWG URL Standard and reads the registrable domain from the Public Suffix List. The canonical form drops tracking parameters and says so in its flags.
WebAddress web = WebAddress.Parse("HTTPS://Shop.Example.co.uk:443/a/../Sale?utm_campaign=fall&id=7"); Console.WriteLine(web.Canonical); Console.WriteLine($"{web.Host} | {web.RegistrableDomain} | {web.Flags}"); Console.WriteLine($"key {web.Key}");
https://shop.example.co.uk/Sale?id=7 shop.example.co.uk | example.co.uk | TrackingParametersRemoved key example.co.uk/sale?id=7
The scheme and host are lowercased, the default port and the .. segment are gone, and utm_campaign is removed while id stays. co.uk is a public suffix, so the registrable domain is example.co.uk. Web addresses lists the tracking parameters that are removed and the social-profile flags.
Read a postal address
PostalAddress.Parse reads free text, writes it back in the postal authority’s style, and validates it against the country’s structure. Full free-text parsing covers the United States, Canada, Mexico, Australia, the United Kingdom and Crown dependencies, and the Latin-script countries of Europe; every other country has the structural layer (fields, layout, required fields, postcode patterns).
PostalAddress address = PostalAddress.Parse("Jo Example\n123 north main street suite 400\nspringfield, il 62704"); Console.WriteLine($"{address.Country.Code} | {address.Locality} | {address.AdminArea} | {address.PostalCode} | {address.Coverage}"); Console.WriteLine(address.ToString(AddressStyle.Postal, AddressLayout.Label)); Console.WriteLine(address.ToString(AddressStyle.Display, AddressLayout.SingleLine)); Console.WriteLine($"{address.Validate().Count} structural issues");
US | springfield | IL | 62704 | Full JO EXAMPLE 123 N MAIN ST STE 400 SPRINGFIELD IL 62704 Jo Example, 123 N Main St Ste 400, Springfield, IL 62704 0 structural issues
The country came from the text: a state and a ZIP code. The fields keep what was written (springfield), and the two styles standardize it: Postal is USPS Publication 28 (capitals, N, ST, STE), and Display is the same words in title case.
When the text shows its country, the country is read from it and flagged CountryInferred. When it doesn’t, PostalAddress refuses it, and you give the country:
// The country is read from the text when the text shows it ... PostalAddress inferred = PostalAddress.Parse("Musterstraße 12, 10115 Berlin"); Console.WriteLine($"{inferred.Country.Code} | {inferred.Flags} | {inferred.ToString(AddressStyle.Postal, AddressLayout.SingleLine)}"); // ... and text that doesn't say which country it is in is refused: give the country. string market = "Am Markt 5, 10117 Berlin"; Console.WriteLine(PostalAddress.TryParse(market, out _) ? "parsed" : "refused: the country can't be told"); Console.WriteLine(PostalAddress.Parse(market, Region.Parse("DE")).ToString(AddressStyle.Postal, AddressLayout.SingleLine));
DE | CountryInferred | Musterstraße 12, 10115 BERLIN refused: the country can't be told Am Markt 5, 10117 BERLIN
In the README’s words, the rules “never read a text in the wrong country and never print a value the text does not contain.” When the country can’t be told explains the inference, and each country group has its own page under Postal addresses.
Extract contacts from a web page
ContactExtractor reads a page you give it: structured markup (JSON-LD, microdata, RDFa Lite, h-card, mailto: and tel: links, <address>) and the visible text. It groups what it finds into person and organization records. It never fetches anything.
string html = """ <!doctype html> <html lang="en-US"> <head> <title>Contact · Example Bakery</title> <script type="application/ld+json"> {"@context": "https://schema.org", "@type": "Bakery", "name": "Example Bakery", "telephone": "+1 217 555 0123", "url": "https://bakery.example.com/", "address": {"@type": "PostalAddress", "streetAddress": "10 Elm Street", "addressLocality": "Springfield", "addressRegion": "IL", "postalCode": "62704", "addressCountry": "US"}} </script> </head> <body> <h1>Contact us</h1> <section> <h2>Wholesale orders</h2> <p>Jo Example, Head Baker</p> <p>Phone: (217) 555-0188</p> <p>Email: jo [at] example [dot] com</p> </section> </body> </html> """; ExtractResult result = new ContactExtractor().ExtractHtml(html); foreach (ContactRecord r in result.Records) { Console.WriteLine($"{r.Kind}: {r.Name?.Text}"); foreach (ExtractedField f in r.Fields) Console.WriteLine($" {f.Kind,-8} {f.Text.Replace("\n", ", "),-42} {f.Source} {f.Flags}"); }
Organization: Example Bakery Name Example Bakery JsonLd None Phone +1 217 555 0123 JsonLd None Website https://bakery.example.com/ JsonLd None Address 10 Elm Street, Springfield, IL, 62704, US JsonLd Composed Person: Jo Example Name Jo Example Text None Phone (217) 555-0188 Text RegionInferred Email jo@example.com Text Deobfuscated
The bakery comes from its JSON-LD, and its address is Composed from the structured parts. Jo Example comes from the visible text. The national phone number took its region from the page’s lang="en-US" (RegionInferred), and the [at]/[dot] email was decoded (Deobfuscated). In the docs’ words, “Job titles are evidence only and are never returned.” Every field also carries its offsets into the page, a confidence and its normalized value. ExtractText does the same for plain text such as a CRM note.
Names, emails, phones and addresses are personal data. In the docs’ words, extraction “runs offline on exactly what the caller passes in, and the library keeps no copy; responsibility for a lawful basis and for compliance with data-protection law (for example the GDPR) for what is extracted and what is done with it sits with the caller.” See extraction.
Next
- Overview: the packages, the quick start and every result with what it was measured against.
- Coming from another library: libphonenumber, libpostal, python-nameparser and probablepeople, call by call.
- Person names and company names: titles, particles, name order, several people in one string, and ISO 20275 legal forms.
- Geocoder, carrier and time zones:
RecursiveAI.OneContact.Phones.Geo, with the original-carrier caveat.