모 웹사이트는 페이지에 있는 어떤 영역을 클릭하면, 그 사이트 내에서 사용할 수 있는 재화를 지급한다.
하지만 그 영역의 수가 결코 적지 않다. 일일이 클릭하기에 꽤 번거로운 양이고 특정 주기로 갱신 또한 이루어진다.
이제 자동화로 편해져 보려 한다.
하지만 여기선 클릭 자동화에 대한 얘기 보단 다른 걸 설명하려고 한다. 그런 자동화는 다른 데 더 좋은 설명이 많다.
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;
}
가장 아래를 보면 알 수 있듯이 아래의 과정을 거친다.
- 최상단에서 로깅 클래스 선언
- 자격 증명 정보 가져오기
- 처리할 웹사이트의 URL 리스트 가져오기
- 처리 정보를 출력해 줄 스레드 실행
- URL 처리 함수 호출
- 완료되면 종료
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. 자격 증명
이리저리 가장 검색을 많이 한 부분.
- 어디서든 사용할 수 있어야 함
- 개발자가 지정한 별도의 키를 사용하지 않고 로컬에서 해결할 수 있어야 함 - 자격 증명 정보가 암호화되어 저장된 파일은 하드웨어에 종속
- 다른 컴퓨터에서 해당 파일 복호화 불가 - 매 프로그램 실행 시마다 자격 증명을 입력할 필요 없이 알아서 복호화 후 사용
- 파일의 존재 유무를 확인하고 새로 입력받거나 데이터 리턴
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 |