7
\$\begingroup\$

I have written a small class that works in extracting Snapchat usernames from common conventions of sentences, manipulating the positioning of certain characters to hopefully extract a valid username.

The script does have some partial protection for validity with its call to SnapchatUtilities.IsUsernameWellFormatted(extractedFromConvention), but other than that its treated as a magick library with no assured accuracy, returns null if extraction fails.

Its written to try and keep things simple, rather than repeating the same thing over and over, I try and create a list of the conventions, then loop the logic for checking via TryExtractUsernameFromConvention

Examples

  1. Library will convert a string like "Isn't it a lovely day today? 👻:username1here lets go for a walk" to "username1here"
  2. Library will convert a string like "Some random person walked into my house today. sc ; addthisusername93 - Isn't this area rather populated?" to "addthisusername93"

Class:

public class SnapchatUsernameExtractor
{
    private static readonly List<string> Conventions = new()
    {
        // "sc"
        "sc.", "sc:", "sc :", "sc-", "sc -", "sc~", "sc ~", "sc;", "sc ;", "sc👻", "sc 👻", "sc•", "sc •", "sc//", "sc //", "sc/", "sc|",

        // "snap"
        "snap:", "snap :", "snap-", "snap -", "snap~", "snap ~", "snap;", "snap ;", "snap•", "snap •","snap👻", "snap 👻", "snap/", "snap//",

        // "scm"
        "scm:", "scm :", "scm-", "scm -", "scm~", "scm ~", "scm;", "scm ;", "scm👻", "scm 👻", "scm•", "scm •",

        // "snapchat"
        "snapchat;", "snapchat~", "snapchat-", "snapchat -", "snapchat👻:", "snapchat👻~", "snapchat👻", "snapchat•", "snapchat •",
        "snapchat//", "snapchat |", "snapchat:", "snapchat-",

        // unsorted
        "snap me~", "👻:", "👻-", "👻~", "👻;", "👻•", "𝕊𝕟𝕒𝕡-", "👻 -", "snap me", "👻 ", "snapchatt~"
    };

    public static string? TryGetUsernameFromString(string s)
    {
        foreach (var convention in Conventions.Where(s.Contains))
        {
            var extractedFromConvention = TryExtractUsernameFromConvention(convention, s);

            if (!string.IsNullOrEmpty(extractedFromConvention) && SnapchatUtilities.IsUsernameWellFormatted(extractedFromConvention))
            {
                return extractedFromConvention;
            }
        }

        return null;
    }

    private static string? TryExtractUsernameFromConvention(string convention, string str)
    {
        if (str.Contains(convention + " "))
        {
            return str.Substring(str.IndexOf(convention) + convention.Length + 1).Split(" ")[0];
        }

        if (str.StartsWith(convention) && !str.Contains(" "))
        {
            return str.Substring(convention.Length);
        }

        if (str.Contains(convention))
        {
            return str.Substring(str.IndexOf(convention) + convention.Length).Split(" ")[0];
        }

        return null;
    }
}

SnapchatUtilities:

public static class SnapchatUtilities
{
    public static bool IsUsernameWellFormatted(string username)
    {
        if (username.Length is < 3 or > 15)
        {
            return false;
        }

        var allowedCharacters = "abcdefghijklmnopqrstuvwxyz-_.0123456789".ToCharArray();

        if (username.ToCharArray().Any(x => !allowedCharacters.Contains(x)))
        {
            return false;
        }

        if ("0123456789-_.".ToCharArray().Contains(username[0]))
        {
            return false;
        }

        if ("-_.".ToCharArray().Contains(username[username.Length - 1]))
        {
            return false;
        }

        return true;
    }
}
\$\endgroup\$
2
  • \$\begingroup\$ Please revise your question's title to align with the CodeReview's standards \$\endgroup\$ Commented Feb 27, 2023 at 8:42
  • \$\begingroup\$ Your Conventions list looks to have a few shared formats. Separating these out into the root phrases (e.g. sc/snap/scm) and the possible extensions (., :, ~ etc) may make it easier to add new phrases/extensions if this is something you may need to do \$\endgroup\$
    – Luke
    Commented Feb 27, 2023 at 15:47

3 Answers 3

7
\$\begingroup\$

This is kinda fun. I think the obvious thing here is that you are avoiding Regex. I understand that 😁 but this is a good use of them.

I like your list of conventions. Nice way to go.

Regex let you define an expression in a concise format that can be compared to a string to find any matches. By default it will look for any matches within the whole string, but you can force it to only accept matching the whole string by including ^ at the start and $ at the end of your expression.

. means 'any character' + means match the previous thing at least once * means match the previous thing as many times a you want.. including zero

^.+$   matches any string of one character or more.
^.*$   matches any string of zero character or more.

You can also specify the number of times you want something to match.

^.{3}$  -- a string of 3 characters
^.{3,15}$ -- a string of 3 to 15 characters

Let's look at IsUsernameWellFormatted

The length check is good. Clean. Easy to read. If that was all you were doing I would do it that way too... Given you are doing a bunch of checks though... let's look at what Regex would look like.

if (Regex.IsMatch(username, @"^.{3,15}$"))
{
    return false;
}

ok. less readable... but bear with me.

Regex let's you specify a range of characters to match. For example [abcdefg] matches any character from a to g. When the characters are consecutive you can actually pass a range. eg. [a-g] does the same thing.

So, your 'allowedCharacters' check would be:

        if (!Regex.IsMatch(username, @"^[a-z0-9-_.]*$"))
        {
            return false;
        }

likewise.. your 'starts with' check is

        if (!Regex.IsMatch(username, @"^[a-z]"))
        {
            return false;
        }

and ends with

        if (!Regex.IsMatch(username, @"[a-z0-9]$"))
        {
            return false;
        }

But the beauty of regex is that you can combine all these checks...

return Regex.IsMatch(username, @"^[a-z][a-z0-9-_.]{1,13}[a-z0-9]$"))

To visualize this I like to use RailRoad diagrams... Explaining Diagram from Regexper.com

If you imagine you are a train following the track dropping off characters as you go :) the regex matches if it can consume your whole string. (Regexper)

(Note: The 'at most 12 times`... is using the loop track to go back.. so that loop always consumes 1 character, but can be repeated up to 12 more times to consume 13 characters.)

\$\endgroup\$
2
\$\begingroup\$

While I totally agree with Nigel on the regex side of the problem, I can't help to notice that you're not following the convention with your TrySomething methods (see Int.TryParse for example). It should return a bool and pass the string as an out parameter:

private static bool TryExtractUsernameFromConvention(string convention, string str, out string username)
{
    if (str.Contains(convention + " "))
    {
        username = str.Substring(str.IndexOf(convention) + convention.Length + 1).Split(" ")[0];
        return true;
    }

    if (str.StartsWith(convention) && !str.Contains(" "))
    {
        username = str.Substring(convention.Length);
        return true;
    }

    if (str.Contains(convention))
    {
        username = str.Substring(str.IndexOf(convention) + convention.Length).Split(" ")[0];
        return true;
    }
    username = "";
    return false;
}

Same goes for TryGetUsernameFromString :

public static bool TryGetUsernameFromString(string s, out string username)
{
    foreach (var convention in Conventions.Where(s.Contains))
    {
        if (TryExtractUsernameFromConvention(convention, s, out var extractedFromConvention) 
            && !string.IsNullOrEmpty(extractedFromConvention) 
            && SnapchatUtilities.IsUsernameWellFormatted(extractedFromConvention))
        {
            username = extractedFromConvention;
            return true;
        }
    }

    username = "";
    return false;
}
\$\endgroup\$
0
\$\begingroup\$

Another thing you can do is add some Linq.


 foreach (var convention in Conventions.Where(s.Contains))
        {
            var extractedFromConvention = TryExtractUsernameFromConvention(convention, s);

            if (!string.IsNullOrEmpty(extractedFromConvention) && SnapchatUtilities.IsUsernameWellFormatted(extractedFromConvention))
            {
                return extractedFromConvention;
            }
        }

        return null;

In effect you are picking the first value that matches your filter.

return Conventions.Where(s.Contains).FirstOrDefault( c => {
    var extractedFromConvention = TryExtractUsernameFromConvention(convention, s);
    return !string.IsNullOrEmpty(extractedFromConvention) && SnapchatUtilities.IsUsernameWellFormatted(extractedFromConvention)
} ) 

If your IsUsernameWellFormatted handled null, then this would simplify further to...

return Conventions.Where(s.Contains).FirstOrDefault( convention =>
   SnapchatUtilities.IsUsernameWellFormatted( TryExtractUsernameFromConvention(convention, s) ) ) 

You could also do

return Conventions
    .Where(s.Contains)
    .Select(convention => TryExtractUsernameFromConvention(convention, s))
    .FirstOrDefault( SnapchatUtilities.IsUsernameWellFormatted) 

I think Linq is smart enough to delay evaluation of the select until it needs a value for 'firstOrDefault' so as a result I think you only end up calling TryExtractUsernameFromConvention the minimum number of times.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.