NameGender.psm1

Set-StrictMode -Version Latest

$script:DefaultBaseUrl = 'https://namegender.com/api/v1'
# The API's bulk limit; longer pipelines are sent in chunks of this size.
$script:Chunk = 100

function Resolve-NameGenderKey {
    param([string] $ApiKey)

    if ($ApiKey) { return $ApiKey }
    if ($env:NAMEGENDER_API_KEY) { return $env:NAMEGENDER_API_KEY }

    throw 'No API key. Pass -ApiKey or set $env:NAMEGENDER_API_KEY (free key at https://namegender.com).'
}

function Invoke-NameGenderApi {
    param(
        [Parameter(Mandatory)] [string] $Path,
        [Parameter(Mandatory)] [string] $ApiKey,
        [string] $Method = 'POST',
        [hashtable] $Body
    )

    $baseUrl = if ($env:NAMEGENDER_BASE_URL) { $env:NAMEGENDER_BASE_URL.TrimEnd('/') } else { $script:DefaultBaseUrl }
    $request = @{
        Uri         = $baseUrl + $Path
        Method      = $Method
        Headers     = @{ Authorization = "Bearer $ApiKey"; Accept = 'application/json' }
        ErrorAction = 'Stop'
    }

    if ($Body) {
        $request.ContentType = 'application/json; charset=utf-8'
        # UTF-8 bytes, not a string: Windows PowerShell 5.1 would otherwise send
        # names such as Ayşe in the system code page.
        $request.Body = [System.Text.Encoding]::UTF8.GetBytes(($Body | ConvertTo-Json -Depth 5 -Compress))
    }

    try {
        Invoke-RestMethod @request
    } catch {
        # The API's error body is { error, message, request_id, docs }. Surface the
        # reason code so scripts can branch on it instead of on the message text.
        $details = $null
        if ($_.ErrorDetails -and $_.ErrorDetails.Message) {
            try { $details = $_.ErrorDetails.Message | ConvertFrom-Json } catch { $details = $null }
        }

        if ($details -and $details.PSObject.Properties['error']) {
            $exception = [System.Exception]::new("$($details.message) ($($details.error))")
            $exception.Data['error'] = $details.error
            $exception.Data['request_id'] = $details.request_id
            # 422 invalid_input names the field; an unsupported language also lists the supported ones.
            if ($details.PSObject.Properties['field']) { $exception.Data['field'] = $details.field }
            if ($details.PSObject.Properties['supported']) { $exception.Data['supported'] = [string[]] @($details.supported) }
            throw $exception
        }

        throw
    }
}

function Get-NameGender {
    <#
    .SYNOPSIS
    Looks up the gender behind names, email addresses or usernames.

    .DESCRIPTION
    One value calls the single endpoint; several values (from -Name or the
    pipeline) are sent in bulk requests of up to 100. Every value costs one
    credit, unknown results included. Results carry gender, probability,
    sample_size, country, confidence and source.

    .EXAMPLE
    Get-NameGender Andrea -Country IT

    .EXAMPLE
    Get-Content names.txt | Get-NameGender | Export-Csv genders.csv -NoTypeInformation

    .EXAMPLE
    Get-NameGender -Email jane.doe@example.com
    #>

    [CmdletBinding(DefaultParameterSetName = 'Name')]
    param(
        [Parameter(ParameterSetName = 'Name', Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]] $Name,

        [Parameter(ParameterSetName = 'Email', Mandatory, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]] $Email,

        [Parameter(ParameterSetName = 'Username', Mandatory, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]] $Username,

        # Two-letter country code. Andrea is male in Italy and female in Germany.
        [ValidatePattern('^[A-Za-z]{2}$')]
        [string] $Country,

        # Language tag such as it-IT; its region is the country when -Country is absent.
        [string] $Locale,

        # End user IP address; its country is used when neither -Country nor a regional -Locale is sent.
        [string] $Ip,

        # Return the more likely gender even when the evidence is weak.
        [switch] $BestGuess,

        [string] $ApiKey
    )

    begin {
        $key = Resolve-NameGenderKey $ApiKey
        $values = [System.Collections.Generic.List[string]]::new()
        $type = $PSCmdlet.ParameterSetName.ToLowerInvariant()

        $options = @{}
        if ($Country) { $options.country = $Country.ToUpperInvariant() }
        if ($Locale) { $options.locale = $Locale }
        if ($Ip) { $options.ip = $Ip }
        if ($BestGuess) { $options.best_guess = $true }
    }

    process {
        # Blank lines are allowed in (Get-Content gives them) and dropped here.
        $batch = switch ($type) { 'email' { $Email } 'username' { $Username } default { $Name } }
        foreach ($value in $batch) {
            $trimmed = "$value".Trim()
            # Empty lines are not sent: they would be rejected and cost nothing useful.
            if ($trimmed) { $values.Add($trimmed) }
        }
    }

    end {
        if ($values.Count -eq 0) { return }

        if ($values.Count -eq 1) {
            $path = if ($type -eq 'name') { '/gender' } else { "/gender/$type" }
            $body = $options.Clone()
            $body[$type] = $values[0]
            Invoke-NameGenderApi -Path $path -ApiKey $key -Body $body
            return
        }

        for ($i = 0; $i -lt $values.Count; $i += $script:Chunk) {
            $body = $options.Clone()
            $body.names = @($values.GetRange($i, [Math]::Min($script:Chunk, $values.Count - $i)))
            $body.type = $type
            $response = Invoke-NameGenderApi -Path '/gender/bulk' -ApiKey $key -Body $body
            $response.results
        }
    }
}

function Get-NameGenderCountry {
    <#
    .SYNOPSIS
    Which countries a name is recorded in.

    .DESCRIPTION
    Not a country-of-origin or ethnicity inference. registrations is counted
    volume from the countries that publish counted birth statistics;
    attested_in is presence with no weight. Show basis.note next to any
    percentage. One credit per call.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)] [string] $Name,
        [ValidateRange(1, 100)] [int] $Limit = 25,
        [string] $ApiKey
    )

    $key = Resolve-NameGenderKey $ApiKey
    Invoke-NameGenderApi -Path '/gender/countries' -ApiKey $key -Body @{ name = $Name; limit = $Limit }
}

function Get-NameGenderAccount {
    <#
    .SYNOPSIS
    Credit balance and account status. Free: costs no credit.
    #>

    [CmdletBinding()]
    param([string] $ApiKey)

    $key = Resolve-NameGenderKey $ApiKey
    Invoke-NameGenderApi -Path '/me' -ApiKey $key -Method 'GET'
}

function Get-NameGenderSalutation {
    <#
    .SYNOPSIS
    Builds the salutation for a letter or email from full names.

    .DESCRIPTION
    One name calls the single endpoint; several names (from -Name or the
    pipeline) are sent in bulk requests of up to 100, and the results come
    back in input order. One credit per name. Each result carries the formal,
    informal and neutral salutation, plus form, reason, parts, gender and
    probability. When the gender is not certain the neutral form is used:
    form is 'neutral' and reason says why. -BestGuess does not apply here.

    With -Form, only that salutation text is returned, one string per name.

    .EXAMPLE
    Get-NameGenderSalutation 'Dr. Anna Müller' -Language de -Form formal

    .EXAMPLE
    Get-NameGenderSalutation -FirstName Ahmet -LastName Yılmaz -Language tr

    .EXAMPLE
    Get-Content names.txt | Get-NameGenderSalutation -Language de | Select-Object query, form, reason, @{ n = 'formal'; e = { $_.salutation.formal } }
    #>

    [CmdletBinding(DefaultParameterSetName = 'Name')]
    param(
        # Full name, titles included ("Dr. Anna Müller").
        [Parameter(ParameterSetName = 'Name', Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]] $Name,

        # Instead of -Name, when the parts are stored separately. Not parsed.
        [Parameter(ParameterSetName = 'Parts')]
        [string] $FirstName,

        [Parameter(ParameterSetName = 'Parts')]
        [string] $LastName,

        # en, en-US, en-GB, de, de-AT, de-CH, fr, es, it, pt, pt-PT, pt-BR, nl, tr, pl, ja.
        # Default: the language of -Locale, else the main language of the country, else en.
        [string] $Language,

        # Country hint for the gender lookup, as in Get-NameGender.
        [ValidatePattern('^[A-Za-z]{2}$')]
        [string] $Country,

        [string] $Locale,

        [string] $Ip,

        # Known gender; skips the lookup. neutral always gives the neutral form.
        [ValidateSet('male', 'female', 'neutral')]
        [string] $Gender,

        # Below this probability the neutral form is used. API default: 90.
        [ValidateRange(50, 100)]
        [int] $MinProbability,

        # Academic title kept in a separate field, such as Dr.; used in de and en.
        [string] $Title,

        # Return only this salutation text instead of the whole result.
        [ValidateSet('formal', 'informal', 'neutral')]
        [string] $Form,

        [string] $ApiKey
    )

    begin {
        $key = Resolve-NameGenderKey $ApiKey
        $values = [System.Collections.Generic.List[string]]::new()
        $pick = $Form.ToLowerInvariant()

        # Only the options that were given are sent.
        $options = @{}
        if ($Language) { $options.language = $Language }
        if ($Country) { $options.country = $Country.ToUpperInvariant() }
        if ($Locale) { $options.locale = $Locale }
        if ($Ip) { $options.ip = $Ip }
        if ($Gender) { $options.gender = $Gender.ToLowerInvariant() }
        if ($PSBoundParameters.ContainsKey('MinProbability')) { $options.min_probability = $MinProbability }
        if ($Title) { $options.title = $Title }
    }

    process {
        foreach ($value in $Name) {
            $trimmed = "$value".Trim()
            # Blank lines are not sent.
            if ($trimmed) { $values.Add($trimmed) }
        }
    }

    end {
        $results = $null

        if ($PSCmdlet.ParameterSetName -eq 'Parts') {
            if (-not $FirstName -and -not $LastName) { throw 'Pass -FirstName, -LastName or both.' }
            $body = $options.Clone()
            if ($FirstName) { $body.first_name = $FirstName }
            if ($LastName) { $body.last_name = $LastName }
            $results = @(Invoke-NameGenderApi -Path '/salutation' -ApiKey $key -Body $body)
        } elseif ($values.Count -eq 0) {
            return
        } elseif ($values.Count -eq 1) {
            $body = $options.Clone()
            $body.name = $values[0]
            $results = @(Invoke-NameGenderApi -Path '/salutation' -ApiKey $key -Body $body)
        } else {
            $results = for ($i = 0; $i -lt $values.Count; $i += $script:Chunk) {
                $body = $options.Clone()
                $body.names = @($values.GetRange($i, [Math]::Min($script:Chunk, $values.Count - $i)))
                (Invoke-NameGenderApi -Path '/salutation/bulk' -ApiKey $key -Body $body).results
            }
        }

        foreach ($result in $results) {
            if ($pick) { $result.salutation.$pick } else { $result }
        }
    }
}

function Test-NameGenderName {
    <#
    .SYNOPSIS
    Says whether names typed into a form look like real people's names.

    .DESCRIPTION
    One name calls the single endpoint; several names (from -Name or the
    pipeline) are sent in bulk requests of up to 100, and the results come
    back in input order. One credit per name. Each result carries assessment
    (plausible, suspicious or implausible), score (0-100), signals (code,
    severity, part, value), first_name, last_name, name_type and evidence.

    It never calls a name fake: use it to flag records for a look, not to
    reject people automatically. Surnames are judged by their shape only.

    .EXAMPLE
    Test-NameGenderName 'asdf qwerty'

    .EXAMPLE
    Test-NameGenderName -FirstName Jennifer -LastName Null -Country US

    .EXAMPLE
    Import-Csv signups.csv | Select-Object -ExpandProperty FullName | Test-NameGenderName | Where-Object assessment -ne 'plausible'
    #>

    [CmdletBinding(DefaultParameterSetName = 'Name')]
    param(
        # Full name as typed ("Jennifer Null").
        [Parameter(ParameterSetName = 'Name', Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string[]] $Name,

        # Instead of -Name, when the parts are stored separately. Not parsed.
        [Parameter(ParameterSetName = 'Parts')]
        [string] $FirstName,

        [Parameter(ParameterSetName = 'Parts')]
        [string] $LastName,

        # Country hint, as in Get-NameGender.
        [ValidatePattern('^[A-Za-z]{2}$')]
        [string] $Country,

        [string] $Locale,

        [string] $Ip,

        [string] $ApiKey
    )

    begin {
        $key = Resolve-NameGenderKey $ApiKey
        $values = [System.Collections.Generic.List[string]]::new()

        # Only the options that were given are sent.
        $options = @{}
        if ($Country) { $options.country = $Country.ToUpperInvariant() }
        if ($Locale) { $options.locale = $Locale }
        if ($Ip) { $options.ip = $Ip }
    }

    process {
        foreach ($value in $Name) {
            $trimmed = "$value".Trim()
            # Blank lines are not sent.
            if ($trimmed) { $values.Add($trimmed) }
        }
    }

    end {
        if ($PSCmdlet.ParameterSetName -eq 'Parts') {
            if (-not $FirstName -and -not $LastName) { throw 'Pass -FirstName, -LastName or both.' }
            $body = $options.Clone()
            if ($FirstName) { $body.first_name = $FirstName }
            if ($LastName) { $body.last_name = $LastName }
            Invoke-NameGenderApi -Path '/name-check' -ApiKey $key -Body $body
        } elseif ($values.Count -eq 0) {
            return
        } elseif ($values.Count -eq 1) {
            $body = $options.Clone()
            $body.name = $values[0]
            Invoke-NameGenderApi -Path '/name-check' -ApiKey $key -Body $body
        } else {
            for ($i = 0; $i -lt $values.Count; $i += $script:Chunk) {
                $body = $options.Clone()
                $body.names = @($values.GetRange($i, [Math]::Min($script:Chunk, $values.Count - $i)))
                (Invoke-NameGenderApi -Path '/name-check/bulk' -ApiKey $key -Body $body).results
            }
        }
    }
}

Export-ModuleMember -Function Get-NameGender, Get-NameGenderCountry, Get-NameGenderAccount, Get-NameGenderSalutation, Test-NameGenderName