오늘은 어디로 갈까...

Just For Fun 본문

삽질

Just For Fun

剛宇 2009. 3. 31. 17:17
 현재 일하고 있는 곳에서는 가끔 이벤트를 하곤한다.
 해당글에 지정한 시간에 맞춰, 의견쓰기(댓글)를 한 사람에게 선착순으로 공연 티켓을 주는것이다.
 뭐 정직원을 대상으로 하는 이벤트라, 본인같은 일용직 노동자에게 의미가 없는 일이긴 하지만, 기분이 우울한 관계로 & 기분전환의 차원으로, 자동으로 댓글다는 프로그램을 만들어보겠다.

1. 준비물
 - 준비물은 다음과 같다.
  JDK 1.5 이상
  HttpClient v3.1 (http://hc.apache.org/httpclient-3.x/index.html)
   + Commons Codec (http://commons.apache.org/codec/)
   + Commons Logging (http://commons.apache.org/logging/)
   + Log4J (http://logging.apache.org/log4j/1.2/index.html)
  HttpCleaner v2.1 (http://htmlcleaner.sourceforge.net/)

 - HtpClient는 웹브라우저 같은 역할을 하고, Commons Codec은 HttpClient 내에서 사용한다. Common Logging과 Log4J도 HtpClient 내에서 사용함으로 그냥 묻어가는 기분으로 사용한다.
 - HttpCleaner는 대상 html을 쉽게 분석(parsing)하기 위해 필요하다.




2. 자바 프로젝트 생성
 - 이클립스에 자바 프로젝트(Java Project)를 생성한다.  글 제목이 Just For Fun 이니, kr.kangwoo.jff로 이름을 짓겠다. --;
 - log4j를 사용하기로 했으니, 적당한 log4j.xml 파일을 만들어서 src 폴더에 넣어주자.

 - 한 클래스에 몰빵하는식으로 구현할 수 있으나, 쪼개는것(?)을 좋아하는 본인의 성격상 여러 클래스로 나누겠다.
  JFFClient : HttpClient를 이용해서 해당 서버에 접속 후, 작업을 처리한다.
  JFFClientConfig : 접속할 서버 주소, 처리할 작업 정보를 저장하고 있다.
  JFFException : RuntimException을 상속받은 클래스로서, JFF 처리시 발생한 에러를 나타낸다.
  Action : 처리할 작업을 나타내는 인터페이스
  LoginAction : 로그인 작업
  WriteCommentAction : 댓글 쓰기 작업
  JFFTask : 지정한 시간에 작업 처리하기 위한 클래스(JFFClient를 생성후 실행한다.)
  JFFMain : 메인 클래스, 지정한 시간에 해당 작업 처리를 지시한다.
  

3. JFFException 클래스 만들기
 - RuntimeException을 상속받아 만든다. 단순히 메시지 처리용이다. Exception을 남용(?)하면 오버헤드(overhead)가 발생하기는 하나, 제어(?)하기에 편해서 본인은 남용을 하겠다.
package kr.kangwoo.jff.client;

public class JFFException extends RuntimeException {
	
	/**
	 * 
	 */
	private static final long serialVersionUID = 7725404710131495314L;

	public JFFException() {
		super();
	}

	public JFFException(String message) {
		super(message);
	}

	public JFFException(String message, Throwable cause) {
		super(message, cause);
	}

	public JFFException(Throwable cause) {
		super(cause);
	}
}


4. Action 인터페이스 만들기
 - 처리할 작업을 나타내는 인터페이스를 만들자. HttpClient를 넘겨받아 원하는 일을 처리하게 만드는 간단한 인터페이스이다.
package kr.kangwoo.jff.client;

import org.apache.commons.httpclient.HttpClient;

public interface Action {
	void doAction(HttpClient client) throws JFFException;
}


5. JFFClientConfig 클래스 만들기
 - 호스트 명, 포트 번호, 처리할 작업 목록을 수용할 수 있는 클래스이다.
 - 작업은 순차적으로 처리하기 위해 List 객체에 담는다.
package kr.kangwoo.jff.client;

import java.util.ArrayList;
import java.util.List;

public class JFFClientConfig {

	private String host;
	private int port = 80;
	private String protocol = "http";
	
	private List<Action> actions;
	
	public String getHost() {
		return host;
	}
	public void setHost(String host) {
		this.host = host;
	}
	public int getPort() {
		return port;
	}
	public void setPort(int port) {
		this.port = port;
	}
	public String getProtocol() {
		return protocol;
	}
	public void setProtocol(String protocol) {
		this.protocol = protocol;
	}
	public List getActions() {
		return actions;
	}
	public void setActions(List actions) {
		this.actions = actions;
	}
	
	public boolean addAction(Action action) {
		if (this.actions == null) {
			this.actions = new ArrayList<Action>();
		}
		return this.actions.add(action);
	}
}


6. JFFClient 클래스 만들기
 - JFFClientConfig 클래스를 첨자로 넘겨받아, HttpClient를 생성후, 호스트 명, 포트번호, 프로토콜을 지정해주고, 세션을 유지하기 위해서 쿠키(Cookie)를 사용가능하게 지정해준다.
 - 예의상(?) close() 메소드를 만들었으나.... 아무일도 안한다. ^^;
 - 여기서는 응답 html이 간단해서 예전에 만들었던, StringUtils을 이용해서 간단히 처리하였다.


package kr.kangwoo.jff.client;

import java.util.List;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.cookie.CookiePolicy;

public class JFFClient {
	
	private HttpClient client;

	private List<Action> actions;
	
	public JFFClient(JFFClientConfig config) {
		client = new HttpClient();
		client.getHostConfiguration().setHost(config.getHost(), config.getPort(), config.getProtocol());
		client.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
		
		actions = config.getActions();
	}
	
	public void close() {
	}
	
	public void execute() throws JFFException {
		if (actions != null) {
			for (Action action : actions) {
				action.doAction(client);
			}
		}
	}
}


7. LoginAction 클래스 만들기
 - 댓글을 남기기 위해서는 로그인을 먼저 해야한다. 그래서 로그인을 하는 작업 클래스를 만든다.
 - 사이트마다 다르겠지만, 이곳에서는 "id"와 "pwd"라는 파라메터를 POST 방식으로 전송하면 로그인 처리가 일어난다.
 - 세션처리는 HttpClient가 알아서(?) 함으로, 여기서는 로그인이 성공했는지 실패했는지를 판단하는 부분만 구현하면 된다.
package kr.kangwoo.jff.client;

import kr.kangwoo.util.StringUtils;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class LoginAction implements Action {

	private Log log = LogFactory.getLog(LoginAction.class);
	
	private String userId;
	private String userPassword;
	
	public LoginAction(String userId, String userPassword) {
		this.userId = userId;
		this.userPassword = userPassword;
	}
	
	public void doAction(HttpClient client) throws JFFException {
		if (log.isDebugEnabled()) {
			log.debug("로그인(" + userId + ", " + StringUtils.repeat("*", StringUtils.defaultIfNull(userPassword).length()) + ")");
		}
		int statusCode = 0;
		String bodyString = null;
		try {
			NameValuePair id = new NameValuePair("id", userId);
			NameValuePair pwd = new NameValuePair("pwd", userPassword);
			
			PostMethod loginMethod = new PostMethod("/ioffice/Login_bk.jsp");
			loginMethod.setRequestBody(new NameValuePair[] {id, pwd});
			statusCode = client.executeMethod(loginMethod);
			if (statusCode == HttpStatus.SC_OK) {
				bodyString = loginMethod.getResponseBodyAsString();
				parse(bodyString);
				if (log.isDebugEnabled()) {
					log.debug("로그인 성공");
				}
			} else {
				throw new JFFException("로그인에 실패하였습니다. (응답코드:" + statusCode + ")");	
			}
			
		} catch (JFFException e) {
			log.debug("로그인 실패");
			throw e;
		} catch (Exception e) {
			log.debug("로그인 실패");
			throw new JFFException("로그인 하는 중 에러가 발생했습니다.", e);
		}
	}

	protected void parse(String html) {
		if (html != null) {
			String script = StringUtils.trim(StringUtils.substringBetween(html, "<script>", "</script>"));
			if (StringUtils.equals(script, "document.location = \"/ioffice/index.jsp\";")) {
				// 로그인 성공
			} else {
				String msg = StringUtils.substringBetween(script, "alert(\"", "\");");
				if (StringUtils.isNotBlank(msg)) {
					msg = "로그인에 실패하였습니다. (" + msg + ")";
				} else {
					msg = "로그인에 실패하였습니다.";
				}
				throw new JFFException(msg);
			}
		}
	}
}


8. WriteCommentAction 클래스 만들기
 - 본격적인 일(?)을 하는 클래스이다.
 - 댓글 작성에 필요한 파라메터들을 넘겨받아, 댓글 작성을 하도록 한다.
 - 지정한 시간에 선착순이므로, 시간(Date)도 넘겨받아야한다.
 - 불행히도  서버의 시간과 PC의 시간 차이가 발생함으로, 적당히 댓글을 작성하다가, 지정 시간에 댓글 작성을 성공하면 중지시킨다.
 - 댓글 작성이 성공하면 지정시간 이전에 작성한 댓글들은 삭제하도록한다.
 - 댓글 실패시 실패 횟수를 계산하여 중지 시키는 로직을 추가하는게 좋을거 같지만, 귀찮아서 통과~한다.
package kr.kangwoo.jff.client;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import kr.kangwoo.util.StringUtils;

import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpException;
import org.apache.commons.httpclient.HttpStatus;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.htmlcleaner.HtmlCleaner;
import org.htmlcleaner.TagNode;

public class WriteCommentAction implements Action {
	
	private Log log = LogFactory.getLog(WriteCommentAction.class);
	
	private SimpleDateFormat format = new SimpleDateFormat("yyyy.MM.dd HH:mm");
	
	private String userId;
	private String userName;
	private String formNo;
	private String patCd;
	private String opContents;
	private Date commentDate;
	
	private long delay = 1 * 1000;
	
	
	public WriteCommentAction(String userId, String userName, String formNo, String patCd, String opContents, Date commentDate) {
		this.userId = userId;
		this.userName = userName;
		this.formNo = formNo;
		this.patCd = patCd;
		this.opContents = opContents;
		this.commentDate = commentDate;
	}

	public void doAction(HttpClient client) throws JFFException {
		boolean isRunning = true;
		Set<String> delSet = new HashSet<String>();
		List<Comment> comments = null;
		// 작성 시작
		do {
			if (log.isInfoEnabled()) {
				log.info("덧글쓰기 시작");	
			}
			try {
				comments = writeComment(client);
				if (comments != null) {
					if (log.isInfoEnabled()) {
						log.info("덧글쓰기 완료 (" + comments.size() + ")");	
					}
					for (Comment comment : comments) {
						if (comment.date.getTime() >= commentDate.getTime()) {
							if (log.isInfoEnabled()) {
								log.info("[" + comment.no + "] " + comment.content + " (" + format.format(comment.date) + ") OK");	
							}					
							isRunning = false;
							break;
						} else {
							if (delSet.contains(comment.no)) {

							} else {
								delSet.add(comment.no);
								if (log.isInfoEnabled()) {
									log.info("[" + comment.no + "] " + comment.content + " (" + format.format(comment.date) + ") pass");	
								}								
							}
						}
					}					
					Thread.sleep(delay);
				}
			} catch (Exception e) {
				log.error("댓글을 작성하는 중 에러가 발생했습니다.", e);
			}
		} while(isRunning);
		
		// 삭제 시작
		if (log.isInfoEnabled()) {
			log.info(delSet.size() + "개의 댓글을 삭제하겠습니다.");	
		}
		
		for (String commentNo : delSet) {
			try {
				comments = deleteComment(client, commentNo);
				if (log.isInfoEnabled()) {
					log.info("[" + commentNo + "] 댓글을 삭제하였습니다.");	
				}
				Thread.sleep(delay);
			} catch (Exception e) {
				log.error("댓글을 삭제하는 중 에러가 발생했습니다. (" + commentNo + ")", e);
			}
		}
	}
	
	protected List<Comment> writeComment(HttpClient client) throws HttpException, IOException {
		List<Comment> comments = null;
		String job = "update";
		StringBuilder query = new StringBuilder();
		query.append("timeStamp=").append(System.currentTimeMillis());
		query.append("&form_no=").append(formNo);
		query.append("&pat_cd=").append(patCd);
		query.append("&user_id=").append(userId);
		query.append("&user_name=").append(encodeURIComponent(userName));
		query.append("&op_contents=").append(encodeURIComponent(opContents));
		query.append("&job=").append(job);
		query.append("&seq=").append("0");
		
		GetMethod getMethod = new GetMethod("/ioffice/page/Forum/Forum_op.jsp?" + query.toString());	
		
		int statusCode = client.executeMethod(getMethod);
		if (statusCode == HttpStatus.SC_OK) {
			comments = getMyComments(getMethod.getResponseBodyAsString(), opContents);
		} else {
			throw new JFFException("서버로 부터 정상적인 응답을 받지 못했습니다. (STATUS_CODE=" + statusCode + ")");
		}
		return comments;
	}
	
	protected List<Comment> deleteComment(HttpClient client, String seq) throws HttpException, IOException {
		List<Comment> comments = null;
		String job = "delete";
		StringBuilder query = new StringBuilder();
		query.append("timeStamp=").append(System.currentTimeMillis());
		query.append("&form_no=").append(formNo);
		query.append("&pat_cd=").append(patCd);
		query.append("&user_id=").append(userId);
		query.append("&user_name=").append(encodeURIComponent(userName));
		query.append("&job=").append(job);
		query.append("&seq=").append(seq);
		query.append("&auth=").append("3");
		
		
		GetMethod getMethod = new GetMethod("/ioffice/page/Forum/Forum_op.jsp?" + query.toString());	
		
		int statusCode = client.executeMethod(getMethod);
		if (statusCode == HttpStatus.SC_OK) {
			comments = getMyComments(getMethod.getResponseBodyAsString(), opContents);
		} else {
			throw new JFFException("서버로 부터 정상적인 응답을 받지 못했습니다. (STATUS_CODE=" + statusCode + ")");
		}
		
		return comments;
	}

	protected String encodeURIComponent(String s)
			throws UnsupportedEncodingException {
		return URLEncoder.encode(s, "UTF-8");
	}
	
	public List<Comment> getMyComments(String input, String opContents) {
		List<Comment> result = new ArrayList<Comment>();
		HtmlCleaner cleaner = new HtmlCleaner();
		try {
			TagNode node = cleaner.clean(input);
			Object[] objArray = node.evaluateXPath("//table//tr//td[@align='left']//div");
			for (Object obj: objArray) {
				TagNode t = (TagNode)obj;
				if (StringUtils.startsWith(t.getText().toString(), opContents)) {
					Comment c = convertComment(t);
					if (c != null) {
						result.add(c);	
					}
				}
			}
		} catch (Exception e) {
			throw new JFFException("HTML을 분석하는중  에러가 발생했습니다.", e);
		}
		return result;
	}
	
	public Comment convertComment(TagNode tagNode) throws ParseException {
		Comment comment = null;
		TagNode[] spanTags = tagNode.getChildTags();
		if (spanTags != null && spanTags.length == 2) {
			comment = new Comment();
			String onclick = spanTags[1].getAttributeByName("onclick");
			comment.no = StringUtils.substringBetween(onclick, "(", ");");
			String text = tagNode.getText().toString();
			comment.content = StringUtils.substringBefore(text, "   ");
			String dateStr = StringUtils.substringBetween(text, "   (", ")");
			comment.date = format.parse(dateStr);
		}
		return comment;
	}
	
	class Comment {
		String content;
		Date date;
		String no;
	}

}


9. JFFTask 클래스 만들기
 - 작업은 프로그램이 시작시 바로 실행되는게 아니라, 지정한 시간에 실행되어야한다. 그래서 쓰레드로 스케줄링을 해야하는데, 여기서는 자바에서 기본적으로 제공하는 java.util.Timer 클래스를 사용하기로 한다. 이 Timer 클래스에 추가할 수 있는 작업 클래스는 java.util.Task 클래스를 상속받아야함으로, 이 Task 클래스를 상속받은 JFFTask 클래스를 만들겠다.
 - run() 메소드에 처리할 작업을 구현하면 된다.
 - JFFClient를 생성후, execute()메소드를 실행하게 한다.
package kr.kangwoo.jff.client;

import java.util.TimerTask;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

public class JFFTask extends TimerTask {
	
	private Log log = LogFactory.getLog(JFFTask.class);
	
	private JFFClientConfig config;
	
	public JFFTask(JFFClientConfig config) {
		this.config = config;
	}
	
	@Override
	public void run() {
		if (log.isDebugEnabled()) {
			log.debug("JFFTask 시작");
		}
		JFFClient client = null;
		try {
			client = new JFFClient(config);
			client.execute();
		} catch (Exception e) {
			log.error("작업 실행 중 에러가 발생했습니다.", e);
		} finally {
			if (client != null) try {client.close();} catch(JFFException je) {}
		}
		if (log.isDebugEnabled()) {
			log.debug("JFFTask 종료");
		}
	}
}


10. JFFMain 클래스 만들기
 - 실제적으로 프로그램을 실행하는 메인 클래스이다.
 - 내부적으로 java.util.Timer 클래스를 생성하여,  JFFTask를 실행하게 한다.
package kr.kangwoo.jff.client;

import java.util.Calendar;
import java.util.Date;
import java.util.Timer;

public class JFFMain extends Timer {

	private Timer timer;
	private JFFTask task;

	public JFFMain(JFFClientConfig config) {
		timer = new Timer();
		task = new JFFTask(config);
	}

	public void schedule(long delay) {
		timer.schedule(task, delay);
	}

	public void schedule(Date time) {
		timer.schedule(task, time);
	}

	public void schedule(long delay, long period) {
		timer.schedule(task, delay, period);
	}

	public void schedule(Date firstTime, long period) {
		timer.schedule(task, firstTime, period);
	}
}


11. 실행해보기
 - JFFClientConfig 클래스를 생성해 서버 정보 및 처리할 작업 진행한다.
 - JFFMain에 JFFClientConfig을 넘겨주고, schudle() 메소드를 이용해 지정한 시간에 실행하게 한다.
 - 아래는 댓글 시간과 실행 시간 값이 동일하다. 그래서 PC의 시간이 서버의 시간보다 빨라야 원하는 결과를 얻을 수 있겠다.
	public static void main(String[] args) throws InterruptedException {
		String host = "test.host.com";
		String userId = "kangwoo";
		String userPassword = "123456";
		String userName = "강우";
		String formNo = "FR2009-03-27100057";
		String patCd = "0";
		String opContents = "자유인/댓글입니다.";
		Date commentDate = toDate(12, 00, 0);

		JFFClientConfig config = new JFFClientConfig();
		config.setHost(host);
		config.addAction(new LoginAction(userId, userPassword));
		config.addAction(new WriteCommentAction(userId, userName, formNo,
				patCd, opContents, commentDate));

		JFFMain main = new JFFMain(config);
		main.schedule(commentDate);
	}

	public static Date toDate(int hour, int minute, int second) {
		Calendar calendar = Calendar.getInstance();
		calendar.set(Calendar.HOUR_OF_DAY, hour);
		calendar.set(Calendar.MINUTE, minute);
		calendar.set(Calendar.SECOND, second);
		calendar.set(Calendar.MILLISECOND, 0);
		return calendar.getTime();
	}


12. 넋두리
 - 테스트 케이스를 만들어서 작업을 하였지만, 보여주기 민망하여 기본(?) 소스만 설명하였다. ^^;
 - 구현보다 구조를 생각하는데 더 많은 시간을 소비하였지만, 별로 좋은 구조가 아니다. (이런걸 시간낭비라고 한다. ㅠㅠ)
 - 단일 쓰레드에서만 정상적인 작동을 보장한다.
 - 기분 전환용 프로그램이라, A/S는 없다.