Can a regex reliably extract phone numbers from text?

The best of four commonly copied phone regexes found 28.94% of 3,801 labelled phone numbers, at F1 0.4356. libphonenumber-js reached F1 0.8534. A regex tuned for Vietnamese and international formats beat it on the formats it was written for, then found 22.79% of the formats it was not.

Can a regex reliably extract phone numbers from text?

No. The best of four commonly copied regexes found 28.94% of the phone numbers in our 3,000-snippet test corpus (F1 0.4356). libphonenumber-js found 76.80% at F1 0.8534. The surprise came from the other direction. A regex I wrote for Vietnamese and international formats beat libphonenumber on F1, 0.8790, and then found 22.79% of four formats I had not written it for.

So a regex can extract phone numbers reliably from exactly the formats its author imagined. Everyone who reaches for one to pull numbers out of emails, business cards or chat logs is betting that the input stays inside that list.

The short answer

  • Copied regexes are guessing. Across 3,801 labelled numbers the four patterns scored recall 0.2883, 0.3596, 0.0816 and 0.2894. The US-shaped \(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4}) found 0 of 255 Vietnamese mobiles written 0912.345.678.
  • Vietnamese tax codes caused more false positives than any other distractor: 1,079 across the nine methods tested. The two North-American regexes matched all 426 tax codes, all 387 citizen ID numbers and all 404 order IDs. Dates caused zero regex false positives.
  • A tuned regex is only as good as your list of formats. Recall 1.0000 on the 12 formats it was written against, 0.2279 on 4 held-out formats. libphonenumber-js (max metadata, default country VN) scored 0.7402 and 0.8542.
  • libphonenumber-js has its own blind spot. A number followed by 24/7 was found 27.21% of the time, against 82.76% everywhere else.
  • It costs about 33x the CPU time of the tuned regex, and it returns E.164 you can store: 95.2% of its correct hits, against 10.0% of the tuned regex's hits taken as returned.

Apple M3, 16 GB, macOS 26.4.1, Node v23.5.0, libphonenumber-js 1.13.13.

How was this measured?

The corpus is generated, which makes the ground truth exact. A seeded script builds 3,000 short snippets (239,803 characters) in English and Vietnamese: signatures, chat lines, invoice fragments. It records the character offsets of every phone number as it writes them. There are 3,801 phone numbers in 16 formats, and 3,624 distractors of 9 types placed alongside them: dates, invoice and order numbers, currency amounts, IPv4 addresses, 16-digit card-like runs, postal codes, 10-digit Vietnamese tax codes (MST) and 12-digit citizen ID numbers (CCCD). 461 snippets contain no phone number at all. Every number is invented; 3,729 of the 3,801 pass libphonenumber-js's own isValidPhoneNumber() with full metadata.

A prediction counts as a hit when it overlaps a real number, contains all of its digits (the extension may be missing) and adds no digits of its own. Anything else is a false positive, attributed to the distractor it landed on. A regex that returns 345 678 out of 0912 345 678 scores a false positive and a miss, because that is what you would store.

The corpus is synthetic, so the absolute numbers describe it, not your inbox. The relative pattern is what transfers: which formats vanish, which digit runs get mistaken for phones.

Which phone regex do people copy, and how well does it work?

Four shapes turn up again and again in Stack Overflow answers and gists. I removed the ^...$ anchors and added g, which is what anyone extracting rather than validating does:

name pattern
US strict \(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})
digits and separators [+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}
E.164 \+[1-9]\d{1,14}
long international \+((?:9[679]|8[035789]|6[789]|5[90]|42|3[578]|2[1-689])|9[0-58]|8[1246]|6[0-6]|5[1-8]|4[013-9]|3[0-469]|2[70]|7|1)(?:\W*\d){0,13}\d

Measured on the full corpus:

method hits false positives precision recall F1
US strict regex 1,096 1,949 0.3599 0.2883 0.3202
digits and separators regex 1,367 1,677 0.4491 0.3596 0.3994
E.164 regex 310 696 0.3082 0.0816 0.1290
long international regex 1,100 149 0.8807 0.2894 0.4356
tuned VN + international regex 3,086 135 0.9581 0.8119 0.8790
libphonenumber-js min, default VN 2,987 721 0.8056 0.7858 0.7956
libphonenumber-js max, default VN 2,919 121 0.9602 0.7680 0.8534
libphonenumber-js max, default US 1,806 189 0.9053 0.4751 0.6232

The best copied regex trails libphonenumber by 0.4178 F1 (calculated from the table). Recall by format shows why. Each copied pattern covers one or two formats perfectly and the rest not at all:

format n US strict separators E.164 long intl tuned libphone max VN
0912345678 239 1.0000 0.9916 0.0000 0.0000 1.0000 0.9665
0912 345 678 222 0.4730 0.4730 0.0000 0.0000 1.0000 0.8153
0912.345.678 255 0.0000 0.0000 0.0000 0.0000 1.0000 0.9961
+84 912 345 678 239 0.0000 0.3431 0.3431 0.8619 1.0000 0.8787
VN landline 024 3936 1188 254 0.0000 0.0000 0.0000 0.2087 1.0000 0.9016
(212) 736-0143 246 1.0000 1.0000 0.0000 0.0000 1.0000 0.1463
+1 212 736 0143 249 0.0000 0.0000 0.0000 0.8835 1.0000 0.9157
with ext. 204 237 1.0000 1.0000 0.0000 0.0000 1.0000 0.2110
+6591234567 228 0.1623 1.0000 1.0000 0.8772 1.0000 0.9649
+44 20 7946 0958 249 0.0000 0.0000 0.0000 0.9076 1.0000 0.8755

The E.164 regex has the worst precision in the set for a quiet reason. On +84 912 345 678 it returns +84, and all 696 of its false positives are fragments of real numbers like that one.

The textbook example is broken too. (555) 123-4567 matches the US strict regex. libphonenumber-js, with min and with max metadata, finds nothing in it, and isValidPhoneNumber() returns false. The number in every tutorial is not a number.

What do phone regexes match that is not a phone number?

Copied phone regexes matched every Vietnamese tax code, citizen ID and order ID; libphonenumber max matched few
cause (count in corpus) US strict separators long intl tuned libphone min VN libphone max VN
VN tax code (426) 426 426 0 67 89 27
order ID (404) 404 404 0 66 30 9
VN citizen ID (387) 387 387 0 0 0 0
card-like run (410) 144 144 0 2 0 0
invoice number (399) 0 0 0 0 135 0
IPv4 (393) 0 0 0 0 140 1
postal code (397) 0 0 0 0 101 62
date (416) 0 0 0 0 65 0
currency (392) 0 0 0 0 41 3
fragment of a real phone 588 316 149 0 120 19

I expected dates and amounts to be the trap. They were not. 15/09/2026 and 1.250.000 đ break every regex tested at the slash or the dot. The trap is an unbroken run of ten or more digits, and Vietnamese business text is full of them: MST: 0312345678, CCCD 079203001234, Order #5235078275. A regex without digit boundaries finds a ten-digit "phone" inside every one. Summed over all nine methods, tax codes caused 1,079 false positives, order IDs 993, citizen IDs 774. Summed the same way, mangled real numbers caused 1,906, more than any distractor.

Metadata matters for libphonenumber too. The default libphonenumber-js import ships "min" metadata, which in our run accepted 140 IP addresses and 65 dates as Vietnamese numbers. libphonenumber-js/max cut total false positives from 721 to 121. Recall dropped from 0.7858 to 0.7680 along the way. In a spot check, max metadata rejected mobiles beginning 0890, 0891, 0892 and 0895 that min accepted, while 0899 passed both. A ZIP+4 code like 95894-0864 still comes back as +84958940864, 62 times.

Is a regex tuned for Vietnamese numbers good enough?

This is the one I wrote. It encodes Vietnamese mobile prefixes and landline area codes, NANP shape, +CC and extensions, and it refuses to start or end inside a longer digit run:

/(?<![\w+.,\/-])(?:(?:\(\+84\)|\+84|0084)[\s.-]?(?:2\d{1,2}|3[2-9]|5[2689]|7[06-9]|8[1-9]|9\d)(?:[\s.-]?\d){7,8}|\(?0(?:2\d{1,2}\)?|3[2-9]|5[2689]|7[06-9]|8[1-9]|9\d)(?:[\s.-]?\d){7,8}|(?:\+1[\s.-]?)?\(?[2-9]\d{2}\)?[\s.-]?[2-9]\d{2}[\s.-]?\d{4}|\+(?!84|1)[1-9](?:[\s.-]?\(?\d\)?){7,13})(?:\s*(?:ext\.?|x|#)\s*\d{1,5})?(?![\w]|[.,\/-]\d)/g

On the twelve formats above: precision 0.9581, recall 1.0000. On paper that beats libphonenumber, and it runs 33 times faster.

Recall 1.0000 is not a result. It means the corpus and the regex were written by the same person. So after writing the regex I added four formats without changing it: 0912 34 56 78 in pairs, 84 912 345 678 without the plus, +84 (0) 912 345 678, and 0044 20 7946 0958 with an international prefix.

held-out format n tuned regex libphone max VN
0912 34 56 78 211 1.0000 0.8436
84 912 345 678 252 0.0000 0.8651
+84 (0) 912 345 678 231 0.0000 0.8355
0044 20 7946 0958 232 0.0000 0.8707

It caught the pairs by luck and nothing else. Every real inbox has a fifth format you did not think of, and libphonenumber has already thought of it.

Where does libphonenumber-js miss phone numbers?

Two places, and neither is obvious.

The default country decides half the answer. With defaultCountry: 'VN' it found 14.63% of (212) 736-0143 numbers. With 'US' it found 0% of Vietnamese numbers written without +84. On a mixed inbox, one default country gives up recall on the other.

Trailing context. Hotlines are written 0349 283 406 24/7. Our generator put 24/7 after 408 numbers, and libphonenumber max VN found 111 of them (0.2721). Everything else scored 0.8276. Tested in isolation, Hotline 0349 283 406 is found and Hotline 0349 283 406 24/7 is not, while (24/7) in brackets and anytime, 24/7 are both found. That fits libphonenumber's guard against slash-separated dates, but I only measured the behaviour, not the cause.

Can you store what the extractor returns?

Finding the digits is half the job. A CRM needs +84912345678, not 0912.345.678. Calculated from each method's output on its correct hits:

method correct E.164 as returned after stripping separators
long international regex 25.1% 82.3%
tuned regex 10.0% 33.2%
libphonenumber max, default VN 95.2% 95.2%
libphonenumber max, default US 100.0% 100.0%

Stripping separators cannot turn 0912 345 678 into +84912345678, because that needs the country. The other 4.8% for libphonenumber is worse than missing: 139 US numbers came back as confident, well-formed +84 numbers, 206-403-1295 as +842064031295. Store that and the contact is wrong forever.

How much slower is libphonenumber than a regex?

Median of nine interleaved rounds over all 3,000 snippets. Other benchmarks were running on this machine, so read the ratios, not the absolutes:

method snippets/s (median) time vs tuned regex
E.164 regex 5,031,092 0.3x
US strict regex 2,810,412 0.5x
tuned regex 1,496,384 1.0x
libphonenumber min, VN 51,012 29.3x
libphonenumber max, VN 45,599 32.8x
libphonenumber max, US 22,583 66.3x

45,599 snippets a second is still fast enough for any inbox. The cost is not a reason to avoid it.

So what should you use?

Use libphonenumber-js with /max metadata and the default country of the sender, not of your server. Drop hits that sit inside a longer digit run, and treat the returned E.164 as the value you store. If you must use a regex, add digit boundaries before anything else. Then test it on formats you did not write it for, because every regex scores 1.0 on its author's examples. That same check is why a regex redactor that looks fine misses most Vietnamese numbers, and why business-card capture should only trust fields a pattern can prove. If the text comes from mail, read it without leaking it first.

Check it yourself

One dependency. Accuracy numbers reproduce exactly with libphonenumber-js 1.13.13 because the corpus is seeded. Timings will not, and newer metadata may move a few counts.

mkdir phone-bench && cd phone-bench
npm init -y && npm install libphonenumber-js
# save the script below as phone-extract.cjs
node phone-extract.cjs            # 3,000 snippets, 9 timing rounds
// phone-extract.cjs — node 18+, one dependency: npm install libphonenumber-js
// Builds a seeded, labelled corpus of text snippets (every number invented),
// runs copied phone regexes and libphonenumber-js over it, and prints
// precision / recall / F1, recall by format, false positives by distractor,
// E.164 share of output, and median throughput.
'use strict';
const lpnMin = require('libphonenumber-js');
const lpnMax = require('libphonenumber-js/max');

let seed = 20260915;
const rnd = () => { seed = (seed + 0x6D2B79F5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
const int = (a, b) => a + Math.floor(rnd() * (b - a + 1));
const pick = a => a[int(0, a.length - 1)];
const dig = n => { let s = ''; for (let i = 0; i < n; i++) s += int(0, 9); return s; };
const grp = (s, sizes, sep) => { const out = []; let i = 0; for (const n of sizes) { out.push(s.slice(i, i + n)); i += n; } return out.join(sep); };

// ---- phone numbers: [format, text, E.164] -----------------------------------
const VN_MOB = ['86', '96', '97', '98', '32', '33', '34', '35', '36', '37', '38', '39', '88', '91', '94', '81', '82', '83', '84', '85', '89', '90', '93', '70', '76', '77', '78', '79', '56', '58', '59'];
const US_AREA = ['212', '415', '617', '312', '213', '646', '702', '305', '206', '512', '404', '503', '972', '818'];
const usN = () => { let ex; do { ex = String(int(201, 989)); } while (ex === '555' || ex[1] + ex[2] === '11'); return pick(US_AREA) + ex + dig(4); };
const PHONES = {
  vn_mobile_plain: () => { const d = pick(VN_MOB) + dig(7); return ['0' + d, '+84' + d]; },
  vn_mobile_spaced: () => { const d = pick(VN_MOB) + dig(7); return [rnd() < 0.5 ? grp('0' + d, [4, 3, 3], ' ') : grp('0' + d, [3, 3, 4], ' '), '+84' + d]; },
  vn_mobile_dots: () => { const d = pick(VN_MOB) + dig(7); return [grp('0' + d, [4, 3, 3], '.'), '+84' + d]; },
  vn_mobile_dashes: () => { const d = pick(VN_MOB) + dig(7); return [grp('0' + d, [4, 3, 3], '-'), '+84' + d]; },
  vn_mobile_intl: () => { const d = pick(VN_MOB) + dig(7); return [pick(['+84 ' + grp(d, [3, 3, 3], ' '), '+84' + d, '(+84) ' + grp(d, [3, 3, 3], ' ')]), '+84' + d]; },
  vn_landline: () => { const k = int(0, 3);
    if (k === 0) { const d = '24' + pick(['3', '6']) + dig(7); return [grp('0' + d, [3, 4, 4], ' '), '+84' + d]; }
    if (k === 1) { const d = '28' + pick(['3', '6']) + dig(7); return ['(028) ' + grp(d.slice(2), [4, 4], ' '), '+84' + d]; }
    if (k === 2) { const d = '236' + '3' + dig(6); return [grp('0' + d, [4, 3, 4], '.'), '+84' + d]; }
    const d = '24' + '3' + dig(7); return ['+84 ' + grp(d, [2, 4, 4], ' '), '+84' + d]; },
  us_paren: () => { const n = usN(); return [`(${n.slice(0, 3)}) ${n.slice(3, 6)}-${n.slice(6)}`, '+1' + n]; },
  us_separated: () => { const n = usN(); return [grp(n, [3, 3, 4], pick(['-', '.', ' '])), '+1' + n]; },
  us_plus1: () => { const n = usN(); return [pick(['+1 ' + grp(n, [3, 3, 4], ' '), '+1-' + grp(n, [3, 3, 4], '-'), `+1 (${n.slice(0, 3)}) ${n.slice(3, 6)}-${n.slice(6)}`]), '+1' + n]; },
  us_extension: () => { const n = usN(); return [`(${n.slice(0, 3)}) ${n.slice(3, 6)}-${n.slice(6)}` + pick([' ext. ', ' x', ' ext ']) + int(2, 9999), '+1' + n]; },
  e164_compact: () => { const c = pick([['1', usN()], ['44', '207946' + dig(4)], ['65', pick(['8', '9']) + dig(7)], ['61', '29' + dig(7)], ['33', '6' + dig(8)], ['49', '30' + dig(8)], ['81', '3' + dig(8)]]); return ['+' + c[0] + c[1], '+' + c[0] + c[1]]; },
  intl_spaced: () => { const k = int(0, 5);
    const c = [['44', '207946' + dig(4), [2, 4, 4]], ['65', pick(['8', '9']) + dig(7), [4, 4]], ['61', '29' + dig(7), [1, 4, 4]], ['33', '6' + dig(8), [1, 2, 2, 2, 2]], ['49', '30' + dig(8), [2, 4, 4]], ['81', '3' + dig(8), [1, 4, 4]]][k];
    return [`+${c[0]} ` + grp(c[1], c[2], k === 5 ? '-' : ' '), '+' + c[0] + c[1]]; },
  // held out: added after the tuned regex was written, and not tuned for
  holdout_vn_pairs: () => { const d = pick(VN_MOB) + dig(7); return [grp('0' + d, [4, 2, 2, 2], ' '), '+84' + d]; },
  holdout_vn_84_no_plus: () => { const d = pick(VN_MOB) + dig(7); return ['84 ' + grp(d, [3, 3, 3], ' '), '+84' + d]; },
  holdout_vn_plus_zero: () => { const d = pick(VN_MOB) + dig(7); return ['+84 (0) ' + grp(d, [3, 3, 3], ' '), '+84' + d]; },
  holdout_intl_00: () => pick([() => { const d = '207946' + dig(4); return ['0044 ' + grp(d, [2, 4, 4], ' '), '+44' + d]; }, () => { const d = pick(['8', '9']) + dig(7); return ['0065 ' + grp(d, [4, 4], ' '), '+65' + d]; }])(),
};

// ---- distractors: digit runs that are not phone numbers ---------------------
const pad = (n, w) => String(n).padStart(w, '0');
const DISTRACTORS = {
  date: () => { const d = int(1, 28), m = int(1, 12), y = int(2019, 2027); return pick([`${pad(d, 2)}/${pad(m, 2)}/${y}`, `${d}/${m}/${y}`, `${pad(d, 2)}-${pad(m, 2)}-${y}`, `${pad(d, 2)}.${pad(m, 2)}.${y}`, `${y}-${pad(m, 2)}-${pad(d, 2)}`, `${y}-${pad(m, 2)}-${pad(d, 2)} ${pad(int(0, 23), 2)}:${pad(int(0, 59), 2)}`]); },
  order_id: () => pick([`#${int(1, 9)}${dig(9)}`, `DH${dig(10)}`, `${dig(6)}-${dig(6)}`, `${int(1, 9)}${dig(11)}`]),
  invoice_no: () => pick([`INV-${int(2019, 2027)}-${dig(6)}`, `${int(2019, 2027)}/${pad(int(1, 12), 2)}/${dig(5)}`, `0${dig(6)}`]),
  currency: () => pick([`${int(1, 99)}.${dig(3)}.000 đ`, `${int(1, 99)}.${dig(3)}.${dig(3)} VND`, `$${int(1, 9)},${dig(3)}.${dig(2)}`, `${int(1, 9)},${dig(3)},000₫`, `USD ${int(10, 99)},${dig(3)}.${dig(2)}`]),
  ipv4: () => pick([`192.168.${int(0, 255)}.${int(1, 254)}`, `10.${int(0, 255)}.${int(0, 255)}.${int(1, 254)}`, `${int(100, 223)}.${int(10, 255)}.${int(100, 255)}.${int(10, 254)}`]),
  card_like: () => { const n = pick(['4539', '5310', '4111']) + dig(12); return pick([grp(n, [4, 4, 4, 4], ' '), n, grp(n, [4, 4, 4, 4], '-')]); },
  postal_code: () => pick([`${int(1, 9)}${pad(int(0, 99) * 1000, 5)}`, `${int(10000, 99999)}`, `${int(10000, 99999)}-${dig(4)}`]),
  vn_tax_code: () => { const c = pick(['01', '02', '03', '04', '18', '36', '37']) + pick(['0', '1']) + dig(7); return pick([c, `${c}-${dig(3)}`]); },
  vn_citizen_id: () => pick(['001', '079', '031', '048']) + int(0, 3) + dig(8),
};
const P_CTX = [['Call me on ', '.'], ['SĐT: ', ''], ['Tel: ', ' (office)'], ['Hotline ', ' 24/7'], ['Mobile: ', ''], ['Zalo ', ' nhé'], ['reach me at ', ' anytime'], ['Liên hệ ', ' để được tư vấn'], ['', ''], ['phone ', ', thanks']];
const D_CTX = { date: ['meeting on ', 'hạn thanh toán '], order_id: ['Order ', 'Mã đơn hàng: '], invoice_no: ['Invoice ', 'Hóa đơn số '], currency: ['Total: ', 'Tổng tiền '],
  ipv4: ['server at ', 'IP '], card_like: ['card ', 'thẻ '], postal_code: ['ZIP ', 'mã bưu chính '], vn_tax_code: ['MST: ', 'Tax code '], vn_citizen_id: ['CCCD ', 'ID no. '] };
const FILLER = ['Hi Minh,', 'Thanks for the quick reply.', 'Cảm ơn anh.', 'See attached.', 'Best regards,', 'Trân trọng,', 'Let me know if that works.', 'Giao hàng trong tuần.', 'Nguyen Van An — Sales Manager', 'Brightlane Logistics'];

function buildCorpus(n) {
  const out = [];
  for (let i = 0; i < n; i++) {
    const frags = [];
    const np = rnd() < 0.85 ? int(1, 2) : 0, nd = rnd() < 0.8 ? int(1, 2) : 0;
    for (let k = 0; k < np; k++) { const fmt = pick(Object.keys(PHONES)); const [txt, e164] = PHONES[fmt](); const c = pick(P_CTX); frags.push({ pre: c[0], val: txt, post: c[1], gold: { kind: 'phone', fmt, e164 } }); }
    for (let k = 0; k < nd; k++) { const type = pick(Object.keys(DISTRACTORS)); frags.push({ pre: pick(D_CTX[type]), val: DISTRACTORS[type](), post: '', gold: { kind: 'distractor', type } }); }
    for (let k = int(0, 2); k > 0; k--) frags.push({ pre: pick(FILLER), val: '', post: '', gold: null });
    for (let k = frags.length - 1; k > 0; k--) { const j = int(0, k); [frags[k], frags[j]] = [frags[j], frags[k]]; }
    let text = ''; const spans = [];
    frags.forEach((f, idx) => { if (idx) text += pick([' ', '\n', ' | ']); text += f.pre; if (f.gold) spans.push({ ...f.gold, start: text.length, end: text.length + f.val.length, text: f.val }); text += f.val + f.post; });
    out.push({ text, spans });
  }
  return out;
}

// ---- methods -----------------------------------------------------------------
const regexMethod = re => text => [...text.matchAll(re)].map(m => ({ start: m.index, end: m.index + m[0].length, out: m[0] }));
const RE = {
  us_strict: /\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})/g,
  loose_separators: /[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}/g,
  e164: /\+[1-9]\d{1,14}/g,
  intl_long: /\+((?:9[679]|8[035789]|6[789]|5[90]|42|3[578]|2[1-689])|9[0-58]|8[1246]|6[0-6]|5[1-8]|4[013-9]|3[0-469]|2[70]|7|1)(?:\W*\d){0,13}\d/g,
  tuned_vn_intl: /(?<![\w+.,\/-])(?:(?:\(\+84\)|\+84|0084)[\s.-]?(?:2\d{1,2}|3[2-9]|5[2689]|7[06-9]|8[1-9]|9\d)(?:[\s.-]?\d){7,8}|\(?0(?:2\d{1,2}\)?|3[2-9]|5[2689]|7[06-9]|8[1-9]|9\d)(?:[\s.-]?\d){7,8}|(?:\+1[\s.-]?)?\(?[2-9]\d{2}\)?[\s.-]?[2-9]\d{2}[\s.-]?\d{4}|\+(?!84|1)[1-9](?:[\s.-]?\(?\d\)?){7,13})(?:\s*(?:ext\.?|x|#)\s*\d{1,5})?(?![\w]|[.,\/-]\d)/g,
};
const lpn = (lib, cc) => text => lib.findPhoneNumbersInText(text, { defaultCountry: cc }).map(r => ({ start: r.startsAt, end: r.endsAt, out: r.number.number }));
const METHODS = {
  'us_strict regex': regexMethod(RE.us_strict), 'loose_separators regex': regexMethod(RE.loose_separators), 'e164 regex': regexMethod(RE.e164), 'intl_long regex': regexMethod(RE.intl_long), 'tuned_vn_intl regex': regexMethod(RE.tuned_vn_intl),
  'libphonenumber min VN': lpn(lpnMin, 'VN'), 'libphonenumber min US': lpn(lpnMin, 'US'), 'libphonenumber max VN': lpn(lpnMax, 'VN'), 'libphonenumber max US': lpn(lpnMax, 'US'),
};

// ---- scoring -----------------------------------------------------------------
const digits = s => s.replace(/\D/g, '');
const core = t => digits(t.replace(/\s*(?:ext\.?|x|#)\s*\d+$/i, ''));
function score(corpus, fn) {
  const r = { tp: 0, fp: 0, gold: 0, e164raw: 0, e164stripped: 0, byFmt: {}, fpBy: {} };
  for (const { text, spans } of corpus) {
    const golds = spans.filter(s => s.kind === 'phone').map(g => ({ ...g, hit: false }));
    const dis = spans.filter(s => s.kind === 'distractor');
    r.gold += golds.length; for (const g of golds) (r.byFmt[g.fmt] ??= { n: 0, hit: 0 }).n++;
    for (const p of fn(text)) {
      const pd = digits(text.slice(p.start, p.end));
      const g = golds.find(g => !g.hit && p.start < g.end && g.start < p.end && pd.includes(core(g.text)) && pd.length <= digits(g.text).length);
      if (g) { g.hit = true; r.tp++; r.byFmt[g.fmt].hit++; if (p.out === g.e164) r.e164raw++; if (p.out.replace(/[^\d+]/g, '') === g.e164) r.e164stripped++; continue; }
      r.fp++;
      const why = golds.some(g => p.start < g.end && g.start < p.end) ? 'fragment of a real phone' : (dis.find(d => p.start < d.end && d.start < p.end) || { type: 'other text' }).type;
      r.fpBy[why] = (r.fpBy[why] || 0) + 1;
    }
  }
  r.precision = r.tp / (r.tp + r.fp || 1); r.recall = r.tp / r.gold; r.f1 = 2 * r.precision * r.recall / (r.precision + r.recall || 1);
  return r;
}

// ---- run -----------------------------------------------------------------------
const N = +(process.argv[2] || 3000), ROUNDS = +(process.argv[3] || 9);
const corpus = buildCorpus(N);
const all = corpus.flatMap(c => c.spans), phones = all.filter(s => s.kind === 'phone'), dis = all.filter(s => s.kind === 'distractor');
console.log(`node ${process.version}, libphonenumber-js ${require('libphonenumber-js/package.json').version}`);
console.log(`corpus: ${corpus.length} snippets, ${corpus.reduce((a, c) => a + c.text.length, 0)} chars, ${phones.length} phone numbers, ${dis.length} distractors, ${corpus.filter(c => !c.spans.some(s => s.kind === 'phone')).length} snippets with no phone`);
console.log(`gold numbers that libphonenumber-js/max isValidPhoneNumber() accepts: ${phones.filter(p => lpnMax.isValidPhoneNumber(p.e164)).length}/${phones.length}`);
console.log('sample snippet:\n  ' + corpus[1].text.replace(/\n/g, '\n  '));

const f4 = x => x.toFixed(4), res = {};
const sub = (r, held) => { let n = 0, h = 0; for (const [f, v] of Object.entries(r.byFmt)) if (f.startsWith('holdout_') === held) { n += v.n; h += v.hit; } return f4(h / n); };
console.log('\n| method | TP | FP | precision | recall | F1 | recall, tuned-for formats | recall, held-out formats | E.164 as returned | E.164 after stripping separators |\n|---|---|---|---|---|---|---|---|---|---|');
for (const [name, fn] of Object.entries(METHODS)) {
  const r = res[name] = score(corpus, fn);
  console.log(`| ${name} | ${r.tp} | ${r.fp} | ${f4(r.precision)} | ${f4(r.recall)} | ${f4(r.f1)} | ${sub(r, false)} | ${sub(r, true)} | ${(100 * r.e164raw / (r.tp || 1)).toFixed(1)}% | ${(100 * r.e164stripped / (r.tp || 1)).toFixed(1)}% |`);
}
const names = Object.keys(METHODS), fmts = Object.keys(PHONES);
console.log('\nrecall by format\n| format | n | ' + names.join(' | ') + ' |\n|---|---|' + names.map(() => '---').join('|') + '|');
for (const f of fmts) console.log(`| ${f} | ${res[names[0]].byFmt[f].n} | ` + names.map(m => f4(res[m].byFmt[f].hit / res[m].byFmt[f].n)).join(' | ') + ' |');
const why = [...Object.keys(DISTRACTORS), 'fragment of a real phone', 'other text'];
console.log('\nfalse positives by cause (distractor counts: ' + Object.keys(DISTRACTORS).map(t => `${t} ${dis.filter(d => d.type === t).length}`).join(', ') + ')\n| cause | ' + names.join(' | ') + ' |\n|---|' + names.map(() => '---').join('|') + '|');
for (const w of why) console.log(`| ${w} | ` + names.map(m => res[m].fpBy[w] || 0).join(' | ') + ' |');

const texts = corpus.map(c => c.text), times = Object.fromEntries(names.map(n => [n, []]));
for (const fn of Object.values(METHODS)) for (const t of texts.slice(0, 200)) fn(t); // warm-up
for (let round = 0; round < ROUNDS; round++) {
  for (const n of (round % 2 ? [...names].reverse() : names)) { const t0 = process.hrtime.bigint(); for (const t of texts) METHODS[n](t); times[n].push(Number(process.hrtime.bigint() - t0) / 1e6); }
}
const med = a => { const s = [...a].sort((x, y) => x - y); return s[s.length >> 1]; };
const base = med(times['tuned_vn_intl regex']);
console.log(`\nthroughput, median of ${ROUNDS} rounds over ${texts.length} snippets\n| method | median ms | snippets/s | time vs tuned regex |\n|---|---|---|---|`);
for (const n of names) { const m = med(times[n]); console.log(`| ${n} | ${m.toFixed(1)} | ${Math.round(texts.length / (m / 1000))} | ${(m / base).toFixed(1)}x |`); }

console.log('\nthe textbook example:');
for (const [lib, label] of [[lpnMin, 'min'], [lpnMax, 'max']]) console.log(`  ${label}: findPhoneNumbersInText("(555) 123-4567", US) -> ${lib.findPhoneNumbersInText('Call (555) 123-4567', { defaultCountry: 'US' }).length} found; isValidPhoneNumber -> ${lib.isValidPhoneNumber('(555) 123-4567', 'US')}`);
console.log(`  us_strict regex on the same string -> ${JSON.stringify('Call (555) 123-4567'.match(RE.us_strict))}`);

Getting phone numbers out of a pasted signature or a photographed card, and storing them as something you can dial, is the problem Simple CRM is being built around. It is not released yet.