[C#] ###Clicker

2024. 2. 29. 01:27·Study/C++ & C#

모 웹사이트는 페이지에 있는 어떤 영역을 클릭하면, 그 사이트 내에서 사용할 수 있는 재화를 지급한다.

하지만 그 영역의 수가 결코 적지 않다. 일일이 클릭하기에 꽤 번거로운 양이고 특정 주기로 갱신 또한 이루어진다.

이제 자동화로 편해져 보려 한다.

하지만 여기선 클릭 자동화에 대한 얘기 보단 다른 걸 설명하려고 한다. 그런 자동화는 다른 데 더 좋은 설명이 많다.

 

1. Main

프로그램의 주요 동작만 알아보기 위해 Main()만 가져왔다.

 

/// <summary>
/// Holds constant values to be used throughout the application for configuration purposes.
/// </summary>
static class Constants
{
    /// <summary>
    /// Maximum number of iterations to attempt processing URLs.
    /// </summary>
    public const int MAX_ITER = 60;

    /// <summary>
    /// Maximum number of concurrent worker threads allowed to run.
    /// </summary>
    public const int MAX_WORKER = 2;

    /// <summary>
    /// Maximum number of iteration of each set.
    /// </summary> 
    public const int MAX_SET = 10;
}

 

 

/// <summary>
/// The main entry point for the application, which initializes and starts all tasks for processing URLs and displaying progress.
/// </summary>
/// <param name="args">Command-line arguments.</param>
static void Main(string[] args)
{
    Logger logger = new Logger();

    // Local function: Load and Validate Credentials
    void LoadAndValidateCredentials(out string id, out string pw)
    {
        Encryption en = new Encryption();

        bool loadCredRes = en.TryLoadCredentials(out id, out pw);
        if (!loadCredRes && id == "nofile")
        {
            logger.Error("Main error: No credential data exist. Input your credentials for create new data.");
            en.EnterCredentials();
            en.TryLoadCredentials(out id, out pw);
        }
        else if (!loadCredRes || id == null)
        {
            logger.Error("Main error: Error loading credential data. Input your credentials for create new data.");
            en.EnterCredentials();
            en.TryLoadCredentials(out id, out pw);
        }
    }

    // Local function: Get URLs to Process
    List<string> GetUrlsToProcess() => new List<string>
{
    // Predefined list of URLs to process
    ...
};

    // Local function: Start Progress Display
    void StartProgressDisplay()
    {
        Task.Run(() => PrintProgress());
    }

    // Local function: Process URLs
    void ProcessUrls(List<string> urls, string id, string pw)
    {
        Stopwatch sw = Stopwatch.StartNew();
        var tasks = urls.Select(url => Task.Run(() =>
        {
            string threadName = Thread.CurrentThread.ManagedThreadId.ToString();
            try
            {
                UpdateProgress(url, $"[{threadName}]", "Waiting");
                ProcessUrl(url, id, pw, threadName);
            }
            catch (Exception ex)
            {
                logger.Error($"Task in Main error: {ex.ToString()}");
            }
        })).ToArray();
        Task.WhenAll(tasks).Wait();
        sw.Stop();
        Console.WriteLine("All Tasks are Completed");
        logger.Info("All Tasks are Completed");

        TimeSpan ts = sw.Elapsed;
        string elapsedTime = String.Format("{0}min {1}.{2}sec",
            ts.Minutes, ts.Seconds, ts.Milliseconds);
        Console.WriteLine("RunTime: " + elapsedTime);
        logger.Info("RunTime: " + elapsedTime);
    }

    // Main logic starts here
    LoadAndValidateCredentials(out string id, out string pw);
    var urls = GetUrlsToProcess();
    StartProgressDisplay();
    ProcessUrls(urls, id, pw);
    return;
}

 

 

가장 아래를 보면 알 수 있듯이 아래의 과정을 거친다.

 

  1. 최상단에서 로깅 클래스 선언
  2. 자격 증명 정보 가져오기
  3. 처리할 웹사이트의 URL 리스트 가져오기
  4. 처리 정보를 출력해 줄 스레드 실행
  5. URL 처리 함수 호출
  6. 완료되면 종료

 

2. Logger

로거 클래스는 log4net을 활용했다. ILogger 클래스도 존재하지만, log4net은 써 본 적이 없어서 이번에 써보기로 했다.

 

using log4net.Config;

namespace SomethingClicker
{
    public class Logger
    {
        private static Logger instance;
        private static readonly object lockObject = new object();
        private log4net.ILog log;

        public Logger()
        {
            log = log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
            XmlConfigurator.Configure(new FileInfo("log4net.config"));
        }

        // Singleton
        public static Logger Instance
        {
            get
            {
                lock (lockObject)
                {
                    if (instance == null)
                    {
                        instance = new Logger();
                    }
                    return instance;
                }
            }
        }

        public void Fatal(string message)
        {
            log.Fatal(message);
        }

        public void Error(string message)
        {
            log.Error(message);
        }

        public void Warn(string message)
        {
            log.Warn(message);
        }

        public void Debug(string message)
        {
            log.Debug(message);
        }
        public void Info(string message)
        {
            log.Info(message);
        }
    }
}

 

클래스는 싱글톤으로 만들었다. 굳이 따로 인스턴스를 만들어서 쓸데없는 메모리를 잡아먹을 필요는 없다고 봤다.

log4net의 세팅을 위한 config 정보는 구글을 통해 모 블로그에서 얻을 수 있었다.

출처

 

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <log4net>
    <root>
      <level value="ALL"/>
      <appender-ref ref="file"/>
      <appender-ref ref="fatal_file"/>
    </root>
    <appender name="file" type="log4net.Appender.RollingFileAppender">
      <file value="logs\\" />
      <datepattern value="yyyy\\\\MM\\\\yyyy-MM-dd'.log'"/>
      <appendToFile value="true" />
      <rollingStyle value="Date" />
      <staticLogFileName value="false" />
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="[%date][Thread : %thread][%level][%logger] %message%newline" />
      </layout>
    </appender>
    <appender name="fatal_file" type="log4net.Appender.RollingFileAppender">
      <file value="logs\fatal.log" />
      <appendToFile value="true" />
      <rollingStyle value="Size" />
      <maxSizeRollBackups value="500" />
      <maximumFileSize value="10MB" />
      <staticLogFileName value="true" />
      <filter type="log4net.Filter.LevelRangeFilter">
        <param name="LevelMin" value="FATAL" />
        <param name="LevelMax" value="FATAL" />
      </filter>
      <layout type="log4net.Layout.PatternLayout">
        <conversionPattern value="[%date][Thread : %thread][%level][%logger] %message%newline" />
      </layout>
    </appender>
  </log4net>
</configuration>

 

이 내용을 log4net.config 라는 파일을 만들어 저장해 활용한다.

 

이렇게 생성된 로그는 아래와 같은 형식이다.

   at OpenQA.Selenium.Remote.HttpCommandExecutor.Execute(Command commandToExecute)
   at OpenQA.Selenium.Remote.DriverServiceCommandExecutor.Execute(Command commandToExecute)
   at OpenQA.Selenium.WebDriver.Execute(String driverCommandToExecute, Dictionary`2 parameters)
   at OpenQA.Selenium.WebDriver.StartSession(ICapabilities capabilities)
   at OpenQA.Selenium.WebDriver..ctor(ICommandExecutor executor, ICapabilities capabilities)
   at OpenQA.Selenium.Chromium.ChromiumDriver..ctor(ChromiumDriverService service, ChromiumOptions options, TimeSpan commandTimeout)
   at OpenQA.Selenium.Chrome.ChromeDriver..ctor(ChromeDriverService service, ChromeOptions options, TimeSpan commandTimeout)
   at OpenQA.Selenium.Chrome.ChromeDriver..ctor(ChromeOptions options)
   at SomethingClicker.Program.SetupAndLogin(String username, String password, IWebDriver& driver) in SolutionPath\SomethingClicker\Sharp\SomethingClicker\Program.cs:line 132
   at SomethingClicker.Program.ProcessUrl(String url, String username, String password, String threadName) in SolutionPath\SomethingClicker\Sharp\SomethingClicker\Program.cs:line 245
   at SomethingClicker.Program.<>c__DisplayClass11_2.<Main>b__6() in SolutionPath\SomethingClicker\Sharp\SomethingClicker\Program.cs:line 352
[2024-02-28 18:40:15,146][Thread : 1][INFO][SomethingClicker.Logger] All Tasks are Completed
[2024-02-28 18:40:15,147][Thread : 1][INFO][SomethingClicker.Logger] RunTime: 24min 8.282s

 

3. 자격 증명

이리저리 가장 검색을 많이 한 부분.

  1. 어디서든 사용할 수 있어야 함
     - 개발자가 지정한 별도의 키를 사용하지 않고 로컬에서 해결할 수 있어야 함
  2. 자격 증명 정보가 암호화되어 저장된 파일은 하드웨어에 종속
     - 다른 컴퓨터에서 해당 파일 복호화 불가
  3. 매 프로그램 실행 시마다 자격 증명을 입력할 필요 없이 알아서 복호화 후 사용
     - 파일의 존재 유무를 확인하고 새로 입력받거나 데이터 리턴

 

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace SomethingClicker
{
    /// <summary>
    /// Provides methods to encrypt and decrypt data using the system's security features.
    /// </summary>
    public class Encryption
    {
        // Logger instance for logging errors
        private Logger logger = new Logger();

        // File path for storing encrypted credentials
        private const string CredentialsFilePath = "cred.dat";

        /// <summary>
        /// Encrypts a string using the Data Protection API with the LocalMachine scope.
        /// </summary>
        /// <param name="data">The string to be encrypted.</param>
        /// <returns>The encrypted data as a byte array.</returns>
        private byte[] EncryptData(string data)

        /// <summary>
        /// Decrypts a byte array using the Data Protection API with the LocalMachine scope.
        /// </summary>
        /// <param name="encryptedData">The data to be decrypted.</param>
        /// <returns>The decrypted string.</returns>
        private string DecryptData(byte[] encryptedData)

        /// <summary>
        /// Encrypts and saves credentials to a file.
        /// </summary>
        /// <param name="id">The user ID.</param>
        /// <param name="password">The password.</param>
        /// <returns>True if saving is successful, otherwise false.</returns>
        public bool SaveCredentials(string id, string password)

        /// <summary>
        /// Attempts to load and decrypt credentials from a file.
        /// </summary>
        /// <param name="id">Output parameter for the user ID.</param>
        /// <param name="password">Output parameter for the password.</param>
        /// <returns>True if loading and decryption are successful, otherwise false.</returns>
        public bool TryLoadCredentials(out string id, out string password)

        /// <summary>
        /// Interactively prompts the user to enter their credentials, which are then encrypted and saved.
        /// </summary>
        /// <returns>True if the credentials are successfully entered, encrypted, and saved; otherwise false.</returns>
        public bool EnterCredentials()
    }
}

 

클래스는 위와 같이 구성했다.

하나하나 뜯어보자.

 

3-1. byte[] EncryptData(string data)

문자열 데이터를 암호화하는 함수이다. DPAPI를 사용하여 암호화를 진행한다.

/// <summary>
/// Encrypts a string using the Data Protection API with the LocalMachine scope.
/// </summary>
/// <param name="data">The string to be encrypted.</param>
/// <returns>The encrypted data as a byte array.</returns>
private byte[] EncryptData(string data)
{
    try
    {
        // Encrypts the data
        byte[] encryptedData = ProtectedData.Protect(
            Encoding.Unicode.GetBytes(data),
            null,
            DataProtectionScope.LocalMachine);
        return encryptedData;
    }
    catch (Exception ex)
    {
        // Logs any exception that occurs during the encryption process
        logger.Error($"EncryptData error: {ex.Message}");
        throw;
    }
}

 

System.Security.Cryptography의 ProtectedData를 활용한다.

Scope를 LocalMachine으로 잡아서 다른 컴퓨터에선 해당 자격 증명 데이터를 복호화할 수 없다.

 

3-2. string DecryptData(byte[] encryptedData)

암호화된 자격증명 데이터를 복호화한다.

/// <summary>
/// Decrypts a byte array using the Data Protection API with the LocalMachine scope.
/// </summary>
/// <param name="encryptedData">The data to be decrypted.</param>
/// <returns>The decrypted string.</returns>
private string DecryptData(byte[] encryptedData)
{
    try
    {
        // Decrypts the data
        byte[] decryptedData = ProtectedData.Unprotect(
            encryptedData,
            null,
            DataProtectionScope.LocalMachine);
        return Encoding.Unicode.GetString(decryptedData);
    }
    catch (Exception ex)
    {
        // Logs any exception that occurs during the decryption process
        logger.Error($"DecryptData error: {ex.Message}");
        throw;
    }
}

 

 

3-3. bool SaveCredentials(string id, string password)

입력받은 자격증명 데이터를 암호화하여 파일로 저장한다.

/// <summary>
/// Encrypts and saves credentials to a file.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="password">The password.</param>
/// <returns>True if saving is successful, otherwise false.</returns>
public bool SaveCredentials(string id, string password)
{
    try
    {
        // Concatenates ID and password with a colon separator
        string data = id + ":" + password;
        byte[] encryptedData = EncryptData(data);
        if (encryptedData == null)
        {
            logger.Error($"SaveCredentials error: Encrypting Failed");
            return false;
        }

        // Writes the encrypted data to a file
        File.WriteAllBytes(CredentialsFilePath, encryptedData);
        return true;
    }
    catch (Exception ex)
    {
        // Logs any exception that occurs during saving
        logger.Error($"SaveCredentials error: {ex.Message}");
        return false;
    }
}

 

 

3-4. bool TryLoadCredentials(out string id, out string password)

저장된 자격증명 데이터 로드를 시도한다.

/// <summary>
/// Attempts to load and decrypt credentials from a file.
/// </summary>
/// <param name="id">Output parameter for the user ID.</param>
/// <param name="password">Output parameter for the password.</param>
/// <returns>True if loading and decryption are successful, otherwise false.</returns>
public bool TryLoadCredentials(out string id, out string password)
{
    id = password = null;
    try
    {
        // Checks if the credentials file exists
        if (!File.Exists(CredentialsFilePath))
        {
            id = password = "nofile";
            return false;
        }

        // Reads the encrypted data from the file and decrypts it
        byte[] encryptedData = File.ReadAllBytes(CredentialsFilePath);
        string decryptedData = DecryptData(encryptedData);
        string[] parts = decryptedData.Split(':');
        if (parts.Length == 2)
        {
            id = parts[0];
            password = parts[1];
            return true;
        }

        return false;
    }
    catch (Exception ex)
    {
        // Logs any exception that occurs during loading
        logger.Error($"TryLoadCredentials error: {ex.Message}");
        return false;
    }
}

 

데이터가 있다면 복호화된 데이터를 넘겨주고, 파일이 없는 등의 이유로 로드에 실패하면 false를 리턴한다.

 

3-5. bool EnterCredentials()

자격증명 데이터 입력을 위한 함수. 

/// <summary>
/// Interactively prompts the user to enter their credentials, which are then encrypted and saved.
/// </summary>
/// <returns>True if the credentials are successfully entered, encrypted, and saved; otherwise false.</returns>
public bool EnterCredentials()
{
    try
    {
        string id, pw = null;

        // Prompts user for ID and password
        Console.WriteLine("Enter ID and Password");
        Console.Write("ID : ");
        id = Console.ReadLine();

        Console.Write("PW : ");
        while (true)
        {
            ConsoleKeyInfo key = Console.ReadKey(true); // Hides the key press from display
            if (key.Key == ConsoleKey.Enter) // Breaks loop on Enter key
            {
                break;
            }
            else if (key.Key == ConsoleKey.Backspace) // Handles backspace
            {
                if (pw.Length > 0)
                {
                    pw = pw.Substring(0, (pw.Length - 1));
                    Console.Write("\b \b"); // Erases the last character from the console
                }
            }
            else
            {
                pw += key.KeyChar; // Appends character to password
                Console.Write("*"); // Displays '*' instead of the character
            }
        }

        // Encrypts and saves the credentials
        SaveCredentials(id, pw);
        return true;
    }
    catch (Exception ex)
    {
        // Logs any exception that occurs during credential entry
        logger.Error($"EnterCredentials error: {ex.Message}");
        return false;
    }
}

 

표준 입력으로 ID를 받고, ReadKey()를 통해 PW를 입력받는다.

콘솔 창에 PW를 입력중일 때, 해당 문자열이 표시되는 대신 *를 표시하게 했다.

입력이 완료되면 해당 데이터를 저장한다.

보안 상 취약할 수 있는 부분이기도 하다.

 

4. 진행 상황 출력

Python엔 tabulate라는 좋은 물건이 있었다.

아무리 찾아도 C#에 비슷한 기능을 하는 패키지는 없었다. 내가 찾지 못한 것일지도...

그래서 비슷하게나마 흉내 내려고 했다.

 

/// <summary>
/// Continuously prints the progress of all URLs being processed to the console.
/// </summary>
static void PrintProgress()
{
    while (true)
    {
        Console.Clear();
        Console.WriteLine("Something Clicker Ver 2.0");

        // Determine column widths based on maximum lengths of the data
        var columnWidths = new
        {
            Url = Math.Max("URL".Length, progressTracker.Keys.Max(k => k.Length) + 2),
            ThreadName = Math.Max("Thread Name".Length, progressTracker.Values.Max(v => v.ThreadName.Length) + 2),
            Status = Math.Max("Status".Length, progressTracker.Values.Max(v => v.Status.Length) + 2),
            Iteration = Math.Max("Iteration".Length, progressTracker.Values.Max(v => v.Iteration.ToString().Length) + 2),
            Set = Math.Max("Set".Length, progressTracker.Values.Max(v => v.Set.ToString().Length) + 3),
            ErrorCount = Math.Max("ERRCNT".Length, progressTracker.Values.Max(v => v.ErrorCount.ToString().Length) + 3)
        };

        // Print table header
        Console.WriteLine($"{"URL".PadRight(columnWidths.Url)} {"Thread Name".PadRight(columnWidths.ThreadName)} {"Status".PadRight(columnWidths.Status)} {"Iteration".PadRight(columnWidths.Iteration)} {"Set".PadRight(columnWidths.Set)} {"ERRCNT".PadRight(columnWidths.ErrorCount)}");
        Console.WriteLine(new string('-', columnWidths.Url + columnWidths.ThreadName + columnWidths.Status + columnWidths.Iteration + columnWidths.Set + columnWidths.ErrorCount + 5));

        // Print each item in the progress tracker
        foreach (var item in progressTracker)
        {
            // Apply color coding based on status
            Console.ForegroundColor = item.Value.Status switch
            {
                "Running" => ConsoleColor.Green,
                "ERROR" => ConsoleColor.Red,
                "Finished" => ConsoleColor.DarkCyan,
                _ => ConsoleColor.Yellow // For any other status
            };

            Console.WriteLine($"{item.Key.PadRight(columnWidths.Url)} " +
                              $"{item.Value.ThreadName.PadRight(columnWidths.ThreadName)} " +
                              $"{item.Value.Status.PadRight(columnWidths.Status)} " +
                              $"{item.Value.Iteration}/{Constants.MAX_ITER}".PadRight(columnWidths.Iteration + 1) +
                              $"{item.Value.Set}".PadRight(columnWidths.Set + 1) +
                              $"{item.Value.ErrorCount}".PadRight(columnWidths.ErrorCount)
                              );
            Console.ResetColor(); // Reset to default color
        }

        Thread.Sleep(1000); // Refresh every second.
    }
}

 

버전 정보는 임의로 하드코딩 했다.

각 Column의 너비를 계산해서 적당한 간격으로 떨어뜨렸다.

Status에 따라 텍스트의 색도 달리하여 좀 더 구분감을 줄 수 있도록 했다.

결과적으로 아래와 같이 표시된다.

 

 

뭐 적어도 뭐가 뭔지는 구분할 수 있으니 된 게 아닐까.

 

5. URL 처리

URL들을 하나씩 가져와 목표하는 처리를 수행하는 함수다.

/// <summary>
/// Processes a given URL by navigating to it, finding and clicking ads, and then closing extra tabs.
/// </summary>
/// <param name="url">The URL to be processed.</param>
/// <param name="username">The username for login.</param>
/// <param name="password">The password for login.</param>
/// <param name="threadName">The name of the processing thread.</param>
static void ProcessUrl(string url, string username, string password, string threadName)
{
    int totalIterations = Constants.MAX_ITER;
    int iterationsPerSet = Constants.MAX_SET;
    int totalSets = (totalIterations + iterationsPerSet - 1) / iterationsPerSet; // Calculate total sets to process
    int errorCount = 0;

    for (int currentSet = 0; currentSet < totalSets; currentSet++)
    {
        semaphoreSlim.Wait(); // To limit the number of concurrent work threads.
        try
        {
            if (SetupAndLogin(username, password, out IWebDriver driver))
            {
                using (driver)
                {
                    string originalWindow = driver.CurrentWindowHandle;
                    driver.Navigate().GoToUrl(url);

                    // Determine iterations in current set
                    int startIteration = currentSet * iterationsPerSet + 1;
                    int endIteration = Math.Min(startIteration + iterationsPerSet - 1, totalIterations);

                    for (int iteration = startIteration; iteration <= endIteration; iteration++)
                    {
                        try
                        {
                            UpdateProgress(url, threadName, "Running", iteration, currentSet, errorCount);
                            FindAndClickSomething(driver, url);
                            Thread.Sleep(500); // Simulate delay
                            CloseOpenedTabs(driver, originalWindow);
                            driver.Navigate().Refresh(); // Refresh the browser to reset state
                        }
                        catch (Exception ex)
                        {
                            logger.Debug(ex.ToString());
                            UpdateProgress(url, threadName, "ERROR", iteration, currentSet, ++errorCount);
                            break; // Exit the current iteration set on error
                        }
                    }
                }
                driver.Quit(); // Cleanup WebDriver resources
            }
            else
            {
                logger.Error("Login failed during URL processing.");
            }
        }
        finally
        {
            semaphoreSlim.Release(); // Ensure semaphore is released regardless of success or failure
        }
    }

    // Final update indicating process completion
    UpdateProgress(url, $"[{threadName}]", "Finished", totalIterations, totalSets, errorCount);
}

 

특히 여기서 Selenium의 부하는 꽤 높기 때문에 SemaphoreSlim을 통해 동시에 작업할 수 있는 스레드의 개수를 제한하도록 했다.

난 i9-9900K를 사용하고 있고, Release로 빌드한 프로그램을 실행했을 때, 최대 4개 스레드까지 동시 작업을 수행할 수 있는 것으로 확인되었다.

하지만 CPU의 자원을 사실상 90% 이상 점유하게 되기 때문에, 실행 중에 다른 작업을 원활히 수행하기가 어려웠다.

어차피 많은 스레드를 통해 급히 수행해야 할 작업도 아니기에 워커는 2개로 제한한 상태에서 사용하고 있다.

 

각 과정(탭을 여는 등) 사이에 임의로 Sleep()를 통한 딜레이를 주었다.

딜레이 없이 즉시 진행될 때 에러의 발생 확률이 높았으나, 일정 딜레이를 주었을 때 에러가 급감했기 때문이다.

 

이렇게 모든 Iteration이 완료되면 프로그램은 종료된다.

 

 

6. 생각할 점

자격 증명 데이터에 관한 보안의 문제이다.

ProcessUrl()은 종료하기 전까지 자격 증명 정보를 메모리에 갖고 있게 된다.

이는 암호화된 상태가 아니기 때문에 CheatEngine이나 IDA 등의 메모리를 볼 수 있는 프로그램으로 간단히 정보를 확인할 수 있는 상태이다.

해당 함수 내에서 자격 증명 데이터가 필요할 때 복호화를 진행해 넘겨주고 바로 해당 데이터를 메모리에서 지우는 식으로 개선할 필요가 헀다고 생각한다.

'Study > C++ & C#' 카테고리의 다른 글

[C#] ###Clicker 개선판  (0) 2024.03.18
[C#] 심플한 게임 런처  (0) 2024.03.06
[C#] 자동화 플러그인 수정  (0) 2023.10.17
[C++ / Python / DB] ProcedureGenerator  (0) 2023.09.01
[C++ / DB] ORM  (0) 2023.09.01
'Study/C++ & C#' 카테고리의 다른 글
  • [C#] ###Clicker 개선판
  • [C#] 심플한 게임 런처
  • [C#] 자동화 플러그인 수정
  • [C++ / Python / DB] ProcedureGenerator
BVM
BVM
  • BVM
    E:\
    BVM
  • 전체
    오늘
    어제
    • 분류 전체보기 (173)
      • Thoughts (14)
      • Study (75)
        • Japanese (3)
        • C++ & C# (50)
        • Javascript (3)
        • Python (14)
        • Others (5)
      • Play (1)
        • Battlefield (1)
      • Others (10)
      • Camp (73)
        • T.I.L. (57)
        • Temp (1)
        • Standard (10)
        • Challenge (3)
        • Project (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • 본 블로그 개설의 목적
  • 인기 글

  • 태그

    Network
    cloudtype
    네트워크 프로그래밍
    Python
    bot
    7계층
    프로그래머스
    네트워크
    Selenium
    클라우드
    C++
    포인터
    OSI
    베데스다
    Dalamud
    Asio
    docker
    Server
    discord py
    discord
    로깅
    FF14
    JS
    IOCP
    서버
    boost
    스타필드
    암호화
    c#
    db
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
BVM
[C#] ###Clicker
상단으로

티스토리툴바