<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Logan Bishop</title>
    <description>The latest articles on DEV Community by Logan Bishop (@lbishopdev).</description>
    <link>https://dev.to/lbishopdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4195038%2Fa0a52fe2-08f9-4432-83c6-b7650c9d03a8.png</url>
      <title>DEV Community: Logan Bishop</title>
      <link>https://dev.to/lbishopdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lbishopdev"/>
    <language>en</language>
    <item>
      <title>I planted 370 SEO defects in a fake 400-page site to test an auditor</title>
      <dc:creator>Logan Bishop</dc:creator>
      <pubDate>Sat, 24 Oct 2026 17:29:07 +0000</pubDate>
      <link>https://dev.to/lbishopdev/i-planted-370-seo-defects-in-a-fake-400-page-site-to-test-an-auditor-5e7g</link>
      <guid>https://dev.to/lbishopdev/i-planted-370-seo-defects-in-a-fake-400-page-site-to-test-an-auditor-5e7g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Everything below uses a site I generated. These are test results, not results from anyone's real website.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A crawl of your own site already holds most of what an SEO metadata audit needs: titles, descriptions, canonicals, language, Markdown (so headings), word counts. Crawling again to check them is a second bill and a second chance to get blocked. So I wrote an Apify Actor that audits the dataset you already have, such as Website Content Crawler output, and never fetches anything.&lt;/p&gt;

&lt;p&gt;The hard part of building an auditor is not writing the rules. It is knowing the rules fire when they should, and stay quiet when they should not. I had no real site I could publish results about, so I built a fake one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;A seeded generator produces a synthetic 400-page site in Website Content Crawler's shape: &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;metadata.title&lt;/code&gt;, a description, a canonical, Markdown text and a status. Every generated page carries a hidden list of the exact issue codes the audit must report, planted on purpose: titles built right at the length limits, a canonical that points at another canonical, groups of pages that share a title, thin pages, broken status codes.&lt;/p&gt;

&lt;p&gt;The test passes only if the audit reports exactly the planted issues on every page: nothing missing and nothing extra. It does: 400 of 400 pages match, across 54 distinct rule codes.&lt;/p&gt;

&lt;p&gt;On that site the audit reports 370 issues (50 errors, 238 warnings, 82 info), a site score of 94 (grade A), 333 pages graded A and 67 graded B. The top issues:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Very thin content&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical chain or loop&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Canonical invalid&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP error status&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No H1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Site-level analysis finds duplicates across pages: 6 groups of duplicate titles, 3 of duplicate descriptions, 3 of duplicate H1s, 2 of duplicate page text and 3 of URL variants. Every page's score lists the points it lost, for example &lt;code&gt;-6 T005 duplicate title (group dt_1, 2 pages)&lt;/code&gt; or &lt;code&gt;-15 D001 meta description missing&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does and does not prove
&lt;/h2&gt;

&lt;p&gt;It proves the rules do what I wrote them to do, including at the boundaries, and that the scoring adds up. It does not prove the auditor is accurate on real sites. I wrote the generator, the rules and the tests, so this is a self-consistency check, not an independent one. Real crawls will have shapes I did not think of. If a field my adapter expects is missing from your dataset, for example the HTTP status or the Open Graph tags, the checks that need it are skipped and listed in the run summary instead of flagging every page, but I have not yet confirmed those field names against every crawler's current output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed and cost, also on synthetic data
&lt;/h2&gt;

&lt;p&gt;On a laptop, the audit step took about half a second for 10,000 generated pages. In the cloud, a run on 2,000 synthetic pages took 4.0 seconds end to end at 1 GB: loading the dataset about 1.6 s, the audit 0.14 s, writing the results about 0.6 s. Reading and writing the dataset is the slow part, not the auditing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two design choices worth copying
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Skip, don't guess.&lt;/strong&gt; If the dataset has no heading data, the H1 rules do not run, and the run summary says why. Flagging "no H1" on every page of a crawl that simply did not save headings would be a false alarm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explain every point.&lt;/strong&gt; Each issue has a code, a message, evidence and a fix, and the score is 100 minus the listed penalties, with caps per category. You can reproduce any score by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://apify.com/hiredhandai/seo-metadata-auditor" rel="noopener noreferrer"&gt;https://apify.com/hiredhandai/seo-metadata-auditor&lt;/a&gt; ($1.50 per 1,000 pages, pay per use). The prefilled example audits six inline pages in a few seconds. If you run it on a real crawl and it misses something or flags something wrong, I would like to hear about it.&lt;/p&gt;

&lt;p&gt;I'm the author, and I used AI assistance to draft and test this.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webscraping</category>
      <category>apify</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I generated 500 messy scraped leads and tried to clean them: what broke</title>
      <dc:creator>Logan Bishop</dc:creator>
      <pubDate>Wed, 21 Oct 2026 00:39:35 +0000</pubDate>
      <link>https://dev.to/lbishopdev/i-generated-500-messy-scraped-leads-and-tried-to-clean-them-what-broke-o6k</link>
      <guid>https://dev.to/lbishopdev/i-generated-500-messy-scraped-leads-and-tried-to-clean-them-what-broke-o6k</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Disclosure: I built the Actor mentioned at the end of this post. The test data below is synthetic, and I say so wherever it matters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; on a synthetic 500-row file, the cleaner returned the 400 real businesses, merged the 90 planted duplicates and made no false merges. Getting there took three dedupe fixes (shared inboxes, shared name prefixes, numbered branches) and two deployment fixes (dataset permissions, monorepo bundling).&lt;/p&gt;

&lt;p&gt;Scraped lead lists have a reputation for being messy, and everyone nods at that. I wanted numbers, so I built a fixture and tried to clean it with the tool I am writing: an Apify Actor that normalizes, dedupes and scores business records.&lt;/p&gt;

&lt;p&gt;This post is about what broke. The headline results are at the end of the first section, but the bugs are the useful part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixture
&lt;/h2&gt;

&lt;p&gt;I did not want to scrape a real site just to test a cleaner, and I did not want to publish someone else's business data. So I wrote a generator. It is deterministic (seeded), and it produces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;400 distinct businesses with unique names, written out as 500 rows&lt;/li&gt;
&lt;li&gt;3 different scraper field schemas mixed in the same file: &lt;code&gt;title&lt;/code&gt; / &lt;code&gt;emailAddress&lt;/code&gt; / &lt;code&gt;phone&lt;/code&gt; / &lt;code&gt;website&lt;/code&gt;; &lt;code&gt;Company Name&lt;/code&gt; / &lt;code&gt;E-mail&lt;/code&gt; / &lt;code&gt;Phone Number&lt;/code&gt; / &lt;code&gt;URL&lt;/code&gt;; and &lt;code&gt;name&lt;/code&gt; / &lt;code&gt;emails[]&lt;/code&gt; / &lt;code&gt;phones[]&lt;/code&gt; / &lt;code&gt;site&lt;/code&gt; (with &lt;code&gt;street_address&lt;/code&gt; and &lt;code&gt;town&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;90 intentional duplicates: a second sighting of an existing business, pushed through a random schema, with a typo in the name, a missing email, or a missing phone and website&lt;/li&gt;
&lt;li&gt;noise on top: ALL-CAPS and doubled spaces, tracking parameters, doubled &lt;code&gt;@@&lt;/code&gt;, &lt;code&gt;mailto:&lt;/code&gt; prefixes, mojibake accents, disposable and mistyped email domains&lt;/li&gt;
&lt;li&gt;10 junk rows with almost nothing usable in them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is a real duplicate pair from the file (same business, two schemas, one invalid email, one missing website):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Company Name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"  Pioneer  Plumbing "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"E-mail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anna.k@@pioneerplumbing16.co.uk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Phone Number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+44 2054019332"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://pioneerplumbing16.co.uk#contact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Full Address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5449 Market St, London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"City"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Industry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Plumber"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"  Pioneer  Plumbing "&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ANNA.K@PIONEERPLUMBING16.CO.UK"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"site"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"street_address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"5449 Market St, London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"town"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"London"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Plumber"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merged record keeps the valid email from the second row and the phone and website from the first.&lt;/p&gt;

&lt;p&gt;The expected output for the 500-row file is easy to state because I built it: 500 minus 90 duplicates minus 10 junk rows is 400 records. That gives me something a script can assert on.&lt;/p&gt;

&lt;p&gt;The caveat, up front: this is a synthetic file that I wrote, using assumptions I also hold when writing the matcher. A result of "zero errors" on it tells me the code is consistent and guards against regressions. It does not tell you how the tool behaves on your Google Maps export. Treat everything below as a development story, not a benchmark.&lt;/p&gt;

&lt;p&gt;With that said, the final numbers on the fixture:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Records returned&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicates merged&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False merges&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missed duplicates&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run time on Apify&lt;/td&gt;
&lt;td&gt;about 2 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Getting to "zero false merges" is where the work was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: shared email addresses merged unrelated businesses
&lt;/h2&gt;

&lt;p&gt;My first blocking keys were the obvious ones: website domain, normalized phone, email address, and name plus city. If two rows share a key, they are candidates for merging.&lt;/p&gt;

&lt;p&gt;The email key was a mistake. Plenty of small businesses list a personal &lt;code&gt;gmail.com&lt;/code&gt; address, and in a larger list several unrelated businesses will list the same one (a franchise owner, a freelancer who runs three storefronts, a bookkeeper). A few rows also had disposable-domain addresses. Keying on the full address merged all of them into one record.&lt;/p&gt;

&lt;p&gt;The fix was to treat an email as a dedupe key only when it is a business address. Free-mail providers and disposable domains are excluded from the key, but the address is still cleaned, validated and kept on the record. The rule is now: a shared &lt;code&gt;info@acmeplumbing.com&lt;/code&gt; is evidence of one business; a shared &lt;code&gt;someone@gmail.com&lt;/code&gt; is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: "Red Rock Bakery" and "Red Rock Realty"
&lt;/h2&gt;

&lt;p&gt;For fuzzy name matching I used Jaro-Winkler, which rewards a shared prefix. That is what you want for "Acme Plumbing" versus "Acme Plumbing Co", and exactly what you do not want here. Both names start with &lt;code&gt;red rock&lt;/code&gt;, and once two such businesses sit in the same city, the similarity is high enough to merge them: Jaro-Winkler scores &lt;code&gt;red rock bakery&lt;/code&gt; against &lt;code&gt;red rock realty&lt;/code&gt; at 0.931, just above my 0.93 threshold.&lt;/p&gt;

&lt;p&gt;The fix was a word-compatibility rule: after normalizing, if each name has a word the other one lacks, the pair is rejected no matter how high the similarity score is. "Bakery" is not in the other name, "Realty" is not in this one, so they stay separate. A name with only extra words on one side ("Acme Plumbing" and "Acme Plumbing Services") is still a candidate. The rule is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// words match if similar enough; any word containing a digit must match exactly&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wordsMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;jaroWinkler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// reject a pair when each side has a word the other side lacks&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;wordsCompatible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lacking&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;against&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;against&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;wordsMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;lacking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wa&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;lacking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wa&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The follow-up post in this series covers the rule and the similarity numbers in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: "Store 1000" and "Store 1001"
&lt;/h2&gt;

&lt;p&gt;Chain businesses are the worst case for fuzzy matching. Branch names differ by one character, so the similarity is very high: Jaro-Winkler scores &lt;code&gt;store 1000&lt;/code&gt; against &lt;code&gt;store 1001&lt;/code&gt; at 0.96. I only caught this one because a 10,000-row synthetic run merged 5,465 records when I expected about 1,000. Two branches in the same city are two different places, and merging them throws away a real lead.&lt;/p&gt;

&lt;p&gt;The fix is the digit check in the snippet above: numbers in a name must match exactly. If the digit sequences differ, the pair is distinct. This is crude and it is correct far more often than it is wrong. The cost is that a true duplicate can survive when two sources disagree on the number itself, and I would rather leave a duplicate than silently delete a branch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: the Actor could not read the dataset
&lt;/h2&gt;

&lt;p&gt;This one is not about data quality. The input takes a dataset ID so the Actor can be chained after any scraper. It worked locally. The first run on the platform, which uses limited permissions, failed with a 403 because it could not read a dataset it had not created.&lt;/p&gt;

&lt;p&gt;Actors running with limited permissions cannot read arbitrary datasets by ID. The platform expects access to be granted through the input. The fix was to declare the dataset input as a resource picker in the input schema, so the user picks the dataset in the Console and the run is granted access to it.&lt;br&gt;
If you build any Actor that consumes another Actor's output, test it under limited permissions before you ship. It cost me a debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: monorepo plus &lt;code&gt;apify push&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The logic lives in a shared &lt;code&gt;packages/core&lt;/code&gt; package (kept free of Node-only APIs so it can also run in a Cloudflare Worker later), and the Actor lives in a sibling folder. &lt;code&gt;apify push&lt;/code&gt; uploads the Actor folder and builds it on the platform. The platform build does not know about my workspace layout, so the shared package cannot be resolved from the Actor folder alone.&lt;/p&gt;

&lt;p&gt;The fix was to bundle the Actor and its core code into a &lt;code&gt;dist&lt;/code&gt; build before pushing, so the uploaded folder is self-contained. There was a second catch: &lt;code&gt;apify push&lt;/code&gt; skips anything matched by &lt;code&gt;.gitignore&lt;/code&gt;, and &lt;code&gt;dist&lt;/code&gt; was ignored, so the first build died with &lt;code&gt;Cannot find module '/usr/src/app/dist/main.js'&lt;/code&gt;. A &lt;code&gt;!dist/&lt;/code&gt; line in &lt;code&gt;.actorignore&lt;/code&gt; forces it back in. It is a build chore, not a design problem, but it is the kind of thing that costs an hour the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale and cost
&lt;/h2&gt;

&lt;p&gt;The 500-row run is a correctness test. For scale I ran 10,000 records with live DNS checks on every email domain on Apify. That took about 61 seconds. (The email domains in that test were synthetic and mostly do not exist, so real lists can be faster or slower depending on how quickly the domains answer DNS.) The measured platform cost was about $0.005 per 1,000 records returned, so compute is a rounding error next to the price.&lt;/p&gt;

&lt;p&gt;The price I set is $1.50 per 1,000 cleaned records, pay per event, and merged duplicates are not charged. On the fixture that means 400 records, which is about $0.60. I am telling you this so the arithmetic is checkable, not to argue it is cheap or expensive for your situation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write the false-merge tests before the matcher. All three merge bugs were cases I could have listed on day one: shared inboxes, shared prefixes, numbered branches.&lt;/li&gt;
&lt;li&gt;Make every merge explainable. Each output record carries a &lt;code&gt;dupeGroupId&lt;/code&gt; and each score carries a reasons list, which made these bugs visible instead of silent.&lt;/li&gt;
&lt;li&gt;Test on a messier fixture than you think you need. Mine had three schemas and it still did not cover everything real scrapers produce.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;The Actor is called Lead Data Cleaner, Deduper &amp;amp; Scorer. It cleans, dedupes and scores records from any Apify dataset or pasted JSON. It does not scrape, send email or probe mail servers, and it stores nothing after the run. The email check is a DNS lookup of the domain only.&lt;/p&gt;

&lt;p&gt;It is live on Apify Store at &lt;a href="https://apify.com/hiredhandai/lead-data-cleaner" rel="noopener noreferrer"&gt;https://apify.com/hiredhandai/lead-data-cleaner&lt;/a&gt;. Questions or bug reports: &lt;a href="mailto:leadcleaner@lbishop.app"&gt;leadcleaner@lbishop.app&lt;/a&gt;. I am the author, and I am also on X as &lt;a class="mentioned-user" href="https://dev.to/lbishopdev"&gt;@lbishopdev&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have a type of messy record that breaks it, I would like to see the (anonymized) shape of it.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>dataengineering</category>
      <category>typescript</category>
      <category>apify</category>
    </item>
  </channel>
</rss>
