[소스코드] AUTOVOT - 큐레이션(보팅)

in #autovot6 years ago (edited)

안녕하세요 @wonsama 입니다.

AUTOVOT은 특정 룰 기반으로 자동으로 보팅을 해주는 프로그램 입니다.
물론 다 한분한분 찾아가며 글을 읽은 후 보팅하면 좋겠지만 실질적으로 힘들긴한지라...
(시간도 없고, 남는 보팅파워는 아깝기도 하고)
고래 분들이 kr에 더 많이 보팅파워 풀로 써가시면서 매일 보팅을 해주시면 좋을것 같은데 ... 또 어뷰징이니 뭐니로 몰리면 또 흠...

주요 변경 사항

  • BAN 대상 파일 로딩으로 처리
  • 팔로워 수 검증
  • 최근 투표한 사람 보팅제외
  • 소스코드 간결화 ( promise 활용 )

이전 글 : 새로운 실험 : 자동보팅 에서 버전 업 하였습니다.

보팅예시 : 특정 조건 해당 글을 배제하고 보팅을 수행
스크린샷 2018-04-02 오후 5.27.17.png

주요 특징 (보팅 조건)

  • 15분 단위로 수행
  • kr 태그 사용자 중 최신글 기준 100번째 이전 글 부터 조회
  • 제목에서 한글이 포함된 단어만
  • 제목에서 특정 단어 필터링 ( 보팅, 이벤트, 가상화폐 등 )
  • 내용 1000 자 이상
  • 특정 사용자 제외 ( kr-guide 신고 당한 글 등 )
  • 보팅 회수가 0~5회
  • 보상금액 약 0.1$ 이하 ( 보상금액 계산부분은 스달 시세와 연동 되 있어서 차이날 수 있음 )
  • 팔로워 1000명 이하 : 팔로워 많으신 분들은 금방 보팅 수치가 올라감
  • 보상거절 글은 보팅 제외
  • 최근투표한 사람은 보팅제외( 최근 보팅대상 목록 저장 72명 )

등의 룰을 적용하여 실질적으로 덜 알려진(보상이 없는) 글들의 보팅을 하도록 하였습니다.

혹시나 필요하신 분은 소스코드 참조 바랍니다.
(물론 룰은 설정값을 통해 적절하게 변경 가능 합니다. )

또한 피드백은 언제나 환영 입니다.

[알림] 일부 글은 어뷰져로 분류? 되는 글이 포함될 수 있습니다.
해당 사항은 댓글 주시면 보팅제외 대상 추가 및 보팅 회수를 하겠습니다.
프로그램 완성도가 100% 가 아닌지라 지속적으로 보완도록 하겠습니다.

/*
    autovot

    자동 보팅 룰
        15분 단위로 수행
        kr 태그 사용자 중 최신글 기준 100번째 이전 글 부터 조회
        제목에서 한글이 포함된 단어만
        제목에서 특정 단어 필터링 ( 보팅, 이벤트, 가상화폐 등 )
        특정 사용자 제외 ( kr-guide 신고 당한 글 등 )
        보팅 회수가 1~5회 ( 0회는 너무 이상한 글이 많이 탐지됨에 제외, 5회 이상은 이미 유명글 )
        보상금액 약 0.1$ 이하 ( 보상금액 계산부분은 스달 시세와 연동 되 있어서 약간 차이날 수 있음 )
        팔로워 1000명 이하 : 팔로워 많으신 분들은 금방 보팅 수치가 올라감
        보상거절 글은 보팅 제외

    참조
        기존에 보팅 목록에 있더라도 하단에 제가 정한 룰을 벗어나는 글은 임의로 보팅을 회수하는 점을 알려 드립니다.
        제가 정한 룰에 벗어나도 다운보팅은 없으며, 보팅을 회수 할 뿐 입니다. ^^
        룰은 임의로 추가되거나 삭제 될 수 있습니다.
    
    보팅 회수 대상
        저작권 침해 관련 글
        kr-guide 신고건
        이미지 딸랑에 복붙 글
*/

////////////////////////////////////////////////
//
// require
//

const steem = require('steem');
const f = require('util').format;
const fs = require('fs');
const schedule = require('node-schedule');
const dateFormat = require('dateformat');

////////////////////////////////////////////////
//
// settings
//

// API 접속 RPC 서버를 설정한다
steem.api.setOptions({ url: 'https://api.steemit.com' });

// 설정정보
const VCFG = {
    CREATOR : "계정명", /* 글쓴이 */
    PRIVATE_KEY : "암호키", /* PRIVATE 암호키 */
    BODY_MIN : 1000, /* 내용 최소길이 */
    VOTE_MIN : 0, /* 최소 투표 수 */
    VOTE_MAX : 5, /* 최대 투표 수 */
    VOTE_SAVE_MAX : 72, /* 보팅받은 아이디 저장 갯수 - 중복보팅 방지용 */
    VOTE_RATE : 100 * 10, /* 보팅 퍼센트 10000 = 100% */
    VOTE_DOLLOR_DIV : 330125227228, /* 수익을 vote_rshares로 나눠서 해당 값, 가변적 */
    VOTE_DOLLOR_LIMIT : 0.1, /* 수익이 0.5달러 미만 */
    QUERY_COUNT : 100, /* 글 조회 갯수 (최대 100개 임) */
    FOLLOWER_COUNT : 1000
};

// 파일 설정정보
const FCFG = {
    LAST_VOTE :'./lastvote.json', /* 최근 보팅한 author 정보 */
    BAN_TITLE :'./bantitle.json', /* 금지 제목 정보  */
    BAN_ID :'./banauthor.json', /* 제한된 author 정보 */
    PRE_DATA_VOTE : './data/votebot_', /* 일자별 보팅 정보 */
    EXT_TXT : ".txt"
};

////////////////////////////////////////////////
//
// define function
//

// 시간을 연산한다 
// h : 시간 
Date.prototype.addHours = function(h) {
    this.setTime(this.getTime() + (h * 60 * 60 * 1000));
    return this;
}

// created 정보를 Date로 변환
// created : 생성시간 
let getLocalTime = (created)=>{
    created = created.replace("T", " ")
    var t = new Date(created).addHours(9);
    return t;
}

// 출력용 시간 정보처리
// t : 시간정보
let getFormadate = (t)=>{
    return t.toLocaleDateString('ko-KR').substr(2).replace(/-/gi, "/") + " " + t.toLocaleTimeString('en-US', { hour12: false }).substr(0, 8);
}

// 파일을 읽어 배열(json) 형태로 반환한다
// path 읽어들일 파일 경로
let fileToArray = (path)=>{
    try{
        return JSON.parse(fs.readFileSync( path, 'utf-8'));
    }catch(e){
        // 보통 파일이 존재하지 않는 경우임
        // console.log(e);
        return new Array();
    }
}

// 한글 여부를 판단한다
// s : 입력 문자열
let isHangul = (s)=>{
    const pattern = /[\u3131-\u314e|\u314f-\u3163|\uac00-\ud7a3]/g;
    return pattern.test(s);
}


// 글목록 정보를 가져온다 
let getDiscussionsByCreated = ()=>{
    return new Promise( (resolve, reject) => {
        steem.api.getDiscussionsByCreated( {limit: VCFG.QUERY_COUNT,tag: 'kr'}, function(err, result) {
            if(err!=undefined){
                reject(err);
            }
            else if(result.length == 0){
                reject("getAccounts : [ " + name + " ] is not found.");
            }
            else{
                resolve(result);
            }
        });
    });
}

// 계정의 팔로워 팔로잉 카운트 정보를 가져온다
// name : 계정명
let getFollowCount = (name)=>{
    return new Promise( (resolve, reject) => {
        steem.api.getFollowCount( name, function(err, result) {
            if(err!=undefined){
                // 여기 걸릴 일은 없을듯
                // name 을 잘못 입력한 경우에는 count가 둘다 0으로 보여진다.
                reject(err);
            }else{
                resolve(result);
            }
        });
    });
}

// 조회 된 item 기준 해당 여러 조건을 만족하는지 여부를 판단한다
let isMatch = (item)=>{
    return new Promise( (resolve, reject) => {

        const ROOT_TITLE = item.root_title.toUpperCase();   // 제목
        const ITEM_URL = "https://steemit.com" + item.url;
        
        // 제목 검증 : 한글 미포함여부 
        if( !isHangul(ROOT_TITLE) ){
            reject( f("url : [ %s ]\ntitle [ %s ] is not include hangul.\n",ITEM_URL ,ROOT_TITLE ) );
        }

        // 제목 검증 : 금지 단어 포함
        for(var i=0;i<BAN_TITLE.length;i++){
            if (ROOT_TITLE.indexOf(BAN_TITLE[i]) >= 0) {
                // console.log("title banned");
                reject( f("url : [ %s ]\ntitle [ %s ] is banned. ( %s )\n",ITEM_URL ,ROOT_TITLE, BAN_TITLE[i] ) );
                // break;
            }
        }

        // 작가 검증 : 제외대상 작가 여부
        for(var i=0;i<BAN_ID.length;i++){
            if (item.author == BAN_ID[i].id) {

                reject( f("url : [ %s ]\nauthor [ %s ] is banned.\n",ITEM_URL ,item.author ) );
                break;
            }
        }

        // 최근 투표 대상 검증
        for(var i=0;i<lastvote.length;i++){
            if (item.author == lastvote[i]) {
                reject( f("url : [ %s ]\nauthor [ %s ] is voted in %d.\n",ITEM_URL ,ROOT_TITLE, VCFG.VOTE_SAVE_MAX ) );
                break;
            }
        }

        // 보상거절
        if (!item.allow_votes){
            reject( f("url : [ %s ]\nauthor [ %s ] allow_votes is reward reject.\n",ITEM_URL ,item.author ) );
        }

        // 보팅횟수 검증
        const vcnt = item.active_votes.length;
        if (vcnt<VCFG.VOTE_MIN || vcnt>VCFG.VOTE_MAX){
            reject( f("url : [ %s ]\nauthor [ %s ] vote count (%d) not in [ %d ~ %d ]\n",ITEM_URL ,item.author, vcnt, VCFG.VOTE_MIN, VCFG.VOTE_MAX) );
        }

        // 보팅금액 제한 
        const _vdol = isNaN(item.vote_rshares) ? 1 : Number(item.vote_rshares);
        const vdol = Math.round((_vdol / VCFG.VOTE_DOLLOR_DIV) * 100) / 100;
        if (vdol > VCFG.VOTE_DOLLOR_LIMIT){
            reject( f("url : [ %s ]\nauthor [ %s ] vote dollor (%d) is exeed [ %d ] dollor.\n",ITEM_URL,item.author, vdol, VCFG.VOTE_DOLLOR_LIMIT) );
        }

        // 본문길이 제한 
        if (item.body_length < VCFG.BODY_MIN){
            reject( f("url : [ %s ]\nauthor [ %s ] body_length ( %d ) is too short. min (%d)\n",ITEM_URL ,item.author, item.body_length, VCFG.BODY_MIN) );
        }

        // 정상 처리
        resolve(item);
    });
} 

// 필터된 결과의 값으로 투표를 수행한다.
let doVote = (item)=>{
    return new Promise( (resolve, reject) => {
        const OUT_TEMPLATE = "| title | %s | url | https://steemit.com%s | voting | %d | reward | %d | created | %s | gap | %d min |\n";

        // 정보가 있는지 여부를 판단한다
        if(item==null || !item){
            reject("item is empty");
        }
        
        const created = getLocalTime(item.created);
        const created_fmt = getFormadate(created);
        const gap = Math.floor(((new Date() - created) / 1000) / 60); // min
        const _vdol = isNaN(item.vote_rshares) ? 1 : Number(item.vote_rshares);
        const vdol = Math.round((_vdol / VCFG.VOTE_DOLLOR_DIV) * 100) / 100;
        const OUT_RESULT = f(OUT_TEMPLATE, item.title, item.url, item.active_votes.length, vdol, created_fmt, gap);

        const wif = steem.auth.toWif(VCFG.CREATOR, VCFG.PRIVATE_KEY, 'posting');
        try{
            steem.broadcast.vote(wif, VCFG.CREATOR, item.author, item.permlink, VCFG.VOTE_RATE, function(e1, r) {
                // 최근 투표한 아이디 정보 업데이트 
                lastvote.push(item.author);
                if(lastvote.length>=VCFG.VOTE_SAVE_MAX){
                    lastvote.shift();    
                }
                fs.writeFileSync( FCFG.LAST_VOTE, JSON.stringify(lastvote), 'utf-8');
                
                // 날짜별 파일에 보팅 정보 기록
                fs.appendFile( FCFG.PRE_DATA_VOTE + dateFormat(new Date(), "yymmdd") + FCFG.EXT_TXT, OUT_RESULT, function(e2) {
                    // console.log(item);
                });

                // 작업 완료 여부를 알림
                resolve(OUT_RESULT);
            });
        }catch(e3){
            reject(e3);
        }
    });
};

// 투표를 수행한다
async function vote(){

    // START
    console.log("start", getFormadate(new Date()) , "\n" );

    // 목록 정보를 가져온다
    const discussions = await getDiscussionsByCreated().catch(e=>{
        // console.log("cause", e);
        return new Array();
    });

    var findIdx = 1;
    var match = false;
    while(true){

        match = await isMatch(discussions[discussions.length - findIdx]).catch(e=>{
            // console.log("skip cause", e);
            return false;
        });

        // 팔로워 수 제한
        if(match || findIdx>=discussions.length){
            const mc = await getFollowCount(match.author);
            if(mc.follower_count<VCFG.FOLLOWER_COUNT){
                // 찾기 성공 !!
                break;
            }else{
                // console.log( f(" %s follower_count is %d.",match.author, mc.follower_count));
                match = false;
            }
        }
        findIdx++;
    }

    // 보팅을 수행한다
    const voteResult = await doVote(match).catch(e=>{
        console.log("cause", e);
        return false;
    });
    console.log("vote result : " + voteResult);

    // END
    console.log("end", getFormadate(new Date()) );
}

////////////////////////////////////////////////
//
// logic
//

// 가장 최근에 보팅한 아이디 - 동일한 경우 제외 다음 글로 ...
var lastvote = fileToArray( FCFG.LAST_VOTE );

// 제외 대상 제목
const BAN_TITLE = fileToArray( FCFG.BAN_TITLE );

// 제외 대상 아이디
const BAN_ID = fileToArray( FCFG.BAN_ID );

// 시간당 4회 보팅 수행
var j = schedule.scheduleJob('1,15,30,45 * * * *', function() {
    vote();
});
Sort:  

isMatch 코드 리뷰입니다.

  • 내부에 wait 하는 루팅이 없으므로 Promise 로 감쌀 필요는 없어 보입니다.
  • isMatch 는 동사의 중복으로 matches, filter 같은 이름이 일반적입니다.
  • 이 함수는 item 을 검사하는 즉 필터링 하는 역할을 하는데, 필터가 제목, 작가 등등 여러가지입니다. 각각을 별도로 함수로 만들면 좋을 것 같네요. 리턴 값은 items 로 합니다.
  • getDiscussionsByCreated 결과가 item 의 배열인데 item 을 객체화하고 아래처럼 코딩합니다.
items.filterTitle()  // items ==> filter ==> filtered items
     .filterAuthor()
     .filterVote()



이상입니다.

코드리뷰 감사드립니다.

말씀하신 부분을 수정하여 반영하면 소스가 좀 더 말끔해 질 것 같네요 ^^

Loading...

와 보팅 원리를 보니까 뉴비들이 엄청 큰 도움을 받겠네요. 세심한 배려가 느껴집니다 ㅎㅎㅎ

네 뉴비를 위한 것이죠 ㅎㅎ

뉴비들에게 큰 힘이 될 것 같아서 아주 좋네요. 응원 하고 갑니다. ^^

응원 감사합니다. ^^

아주 좋고 좋은

응원 감사합니다.

오! 코드를 잘 분석했네요!
공부에 많이 도움되네요.
특히 시간코드 표현을 잘 배웠네요. 자바스크립트는 따로 배운적이 없고 필요에 따라서 함수를 찾고 코딩을 하다보니 이런 예제로 나온 걸 무척 반가와요. 응용해서 새로운 코딩을 만들고 싶은 욕망이 샘솟기 때문에요.
Steem.js API로 이제 입문한지 몇일 안됐지만 재밌는 것 같아요.
나중에 Steemp API를 D3로 연동해서 시각화 해보고 싶은데 아직은 API 함수 검색과 공부중이라 갈길이 머네요.

저도 취미로 IOT 공부하면서 아두이노랑 라즈베리파이 하는데 ...

  • PIR센서 + 사진기 + 텔레그램 연동해서 누군가 문 앞에 오면 사진 찍어 텔레그램 전송
  • 매직미러도 만들어서 간단한 생활정보 디스플레이

위 2가지 집에서 만들어 쓰고 있는데 잼있기는 웹/서버 개발보다 IOT쪽이 훨 잼있는거 같아요

팔로 드리고 종종 찾아 뵐께요 ^^

코드..라는것은..전 죽었다깨어나도 모를 이야기같아 대단하게 느껴지구..뉴비들께 어떻게 도움주실지 고민하신 모습이 보여요 :) 응원하고 리스팀합니다.

감사 합니다. 뉴비의 진입이 있어야 스팀이 커질거 같아서요 ^^ 행복한 하루되세요

아 이런 원리로 보팅이 된것이군요. 보팅 감사합니다..

넵 보팅액이 크진 않지만... 뭐 기분 좋자나요 ^♡^

async/await 를 쓰니까 순차적으로 읽으면 되니 가독성이 좋아지네요.
혹씨 코드리뷰를 원하시나요?

코드 리뷰 해주시면야 ... 감사하죠 ^^

node.js인가요? 와! 코드 처음 봐요 ~ㅁ~

개인적으로는 파이썬보다 노드가 편한거 같아서 노드를 주로 쓰네요. 자바스크립트가 친숙하다 보니 ^^;

wonsama님 안녕하세요. 오늘 최신글을 보고 알게 되었는데,
정말 유용한정보 감사합니다.ㅋㅋㅋ
팔로우 신청하고 갈게요!! 자주 찾아뵙겠습니다~

네 응원 감사합니다. @uuu95

Coin Marketplace

STEEM 0.32
TRX 0.12
JST 0.034
BTC 64647.93
ETH 3160.25
USDT 1.00
SBD 4.09