Kotlin

NewsFeed - JSoup / Nested RecyclerView

kakaroo 2022. 3. 16. 20:57
반응형

관심있는 뉴스 정보를 매시간마다 서버로 모아 퇴근시에 한번에 보는 어플리케이션을 만들어 보려고 합니다.

서버의 필요성은 현재 찾지 못해서, 현재 시간대의 기사를 검색할 때마다 보여주는 앱으로 만들어 보았습니다.

 

<결과물>

설정화면

 

 

주요 기능들은 아래와 같습니다.

 

1. 뉴스 키워드(키워드 최대갯수는 10개??), 뉴스 크롤링 시간(최소 2시간마다??) 설정 화면 (Settings Activity)

2. 뉴스 검색 결과를 parsing 해서 필요 정보(타이틀, URL, 뉴스시간정보 등)를 수집

3. 위에서 수집한 정보를 서버에 post

4. 서버에 post 한 결과를 가져와서 보여주는 화면 (NewsViewer Activity)

--> 서버가 과연 필요한 것인가?? 뉴스가 시간별로 크게 달라질 것 같지는 않은데,, 고민해보겠습니다.

 

이번 포스팅에서는 위 2번에 해당되는 내용을 주로 다뤄보겠습니다.

 

먼저, Naver의 뉴스 Feed는 아래 URL에 키워드를 추가해 주면 됩니다.

http://newssearch.naver.com/search.naver?where=rss&query=

 

http://newssearch.naver.com/search.naver?where=rss&query=메이저리그 로 검색한 결과는 다음과 같습니다.

 

This XML file does not appear to have any style information associated with it. The document tree is shown below.
<rss xmlns:media="http://search.yahoo.com/mrss/" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<title>네이버 뉴스검색 :: '메이저리그'</title>
<link>https://search.naver.com/search.naver?where=news&query=%EB%A9%94%EC%9D%B4%EC%A0%80%EB%A6%AC%EA%B7%B8&sm=tab_pge&sort=0&photo=0=&field=0&pd=0&ds=&de=&docid=</link>
<description>네이버 뉴스검색 '메이저리그'에 대한 검색결과입니다.</description>
<language>ko</language>
<lastBuildDate>Mon, 14 Mar 2022 12:24:44 +0900</lastBuildDate>
<ttl>5</ttl>
<image>
<title>네이버 뉴스검색 :: '메이저리그'</title>
<link>https://search.naver.com/search.naver?where=news&query=%EB%A9%94%EC%9D%B4%EC%A0%80%EB%A6%AC%EA%B7%B8&sm=tab_pge&sort=0&photo=0=&field=0&pd=0&ds=&de=&docid=</link>
<url>http://imgnews.naver.net/image/news/naverme/news_40x40.jpg</url>
</image>
<item>
<title>"다른 방식으로도 레전드 될 수 있다" 2020 신인왕의 소신 [★수원]</title>
<link>http://star.mt.co.kr/stview.php?no=2022031409550080347</link>
<description>
<![CDATA[ 그런 만큼 목표로 하는 투수도 국내가 아닌 미국 메이저리그에 있었다. 소형준은 "(내 스타일과 관련해 KBO리그에서) 참고하는 투수는 없다. 메이저리그 밀워키의 코빈 번스(27)의 공이 정말 매력적이라 생각한다. 그 선수의... ]]>
</description>
<pubDate>Mon, 14 Mar 2022 12:22:00 +0900</pubDate>
<author>스타뉴스</author>
<category>스포츠</category>
<media:thumbnail url="https://imgnews.pstatic.net/image/thumb140/108/2022/03/14/3035475.jpg"/>
</item>
<item>
<title>키움·LG, 고척서 선발진 대거 투입 점검[스경X현장]</title>
<link>http://sports.khan.co.kr/news/sk_index.html?art_id=202203141214013&sec_id=510201&pt=nv</link>
<description>
<![CDATA[ LG의 새 외국인 투수 플럿코는 메이저리그 통산 88경기에서 14승14패 평균자책 5.39를 기록한 우완이다. 지난해 총액 80만달러에 LG와 계약했다. 지난 3일 NC와의 연습경기에 실전 첫 등판한 플럿코는 2이닝 1피안타 3탈삼진... ]]>
</description>
<pubDate>Mon, 14 Mar 2022 12:15:00 +0900</pubDate>
<author>스포츠경향</author>
<category>스포츠</category>
<media:thumbnail url="https://imgnews.pstatic.net/image/thumb140/144/2022/03/14/799114.jpg"/>
</item>
<item>
<title>유니폼은 팠을까? 트레이드 하루만에 또 트레이드된 선수</title>
<link>https://www.spotvnews.co.kr/news/articleView.html?idxno=510166</link>
<description>

 

위 HTML을 JSoup 을 이용해 parsing 해 보기로 하겠습니다.

JSoup

 

  jsoup 자바(Java)로 만들어진 HTML Parser입니다. 자바로 만들어져있기 때문에, Kotlin에서 역시 jsoup의 기능을 이용해 HTML을 쉽게 다룰 수 있습니다.

 

JSoup 기능

 -  URL, 파일, 문자열을 소스로 하여 HTML을 파싱할 수 있습니다.

 -  DOM 구조를 추적하거나 익숙한 CSS 선택자를 사용하여 데이터를 찾아 추출할 수 있습니다.

 -  문서내의 HTML 요소, 속성, 텍스트를 조작할 수 있습니다.

 

 다음 JSoup 공식 사이트에서 다운로드 및 필요한 정보를 얻을 수 있습니다.

https://jsoup.org/

 

jsoup: Java HTML parser, built for HTML editing, cleaning, scraping, and XSS safety

jsoup: Java HTML Parser jsoup is a Java library for working with real-world HTML. It provides a very convenient API for fetching URLs and extracting and manipulating data, using the best of HTML5 DOM methods and CSS selectors. jsoup implements the WHATWG H

jsoup.org

 

 

jsoup을 build.gradle에 추가해 줍니다.

implementation 'org.jsoup:jsoup:1.11.3'

 

JSoup 은 Async로 구현을 해줘야 합니다.

결과는 onPostExecute 메소드로 리턴을 해 줍니다.

 

뉴스기사들은 <item> </item> tag로 구별할 수 있기 때문에 Elements는 item 으로 식별하고,

selection 된 elements에서 pubDate, title link tag로 각 element를 구합니다.

 

class JSoupParser(val url: String, val callback: MainActivity.onPostExecuteListener): AsyncTask<Void, Void, Void>() {

    var mList: ArrayList<Article> = ArrayList<Article>()

    override fun doInBackground(vararg params: Void?): Void? {
        Log.i(Common.MY_TAG, "URL: $url")

        //val list: ArrayList<Article> = ArrayList()
        val doc: Document = Jsoup.connect(url).get()
        val contentElements: Elements = doc.select("item")
        for ((i, elem) in contentElements.withIndex()) {
            val date = elem.select("pubDate").text()
            val title = elem.select("title").text()
            val link = elem.select("link").text()
            Log.d(Common.MY_TAG, "------------------- $i ---------------")
            Log.d(Common.MY_TAG, date)
            Log.d(Common.MY_TAG, title)
            Log.d(Common.MY_TAG, link)
            mList.add(Article(i, date, title, link))
        }
        return null
    }

    override fun onPostExecute(result: Void?) {
        callback.onPostExecute(mList)
    }
}

selector syntax 사용법은 아래 페이지를 참조했습니다.

https://jsoup.org/cookbook/extracting-data/selector-syntax

 

Use selector-syntax to find elements: jsoup Java HTML parser

Use selector-syntax to find elements Problem You want to find or manipulate elements using a CSS or jquery-like selector syntax. Solution Use the Element.select(String selector) and Elements.select(String selector) methods: File input = new File("/tmp/inpu

jsoup.org

 


위 JSoup 연결시 HTTP 보안상 에러가 발생하므로 아래 링크를 참조해 해결해 줍니다.

https://sskey.tistory.com/53

 

안드로이드 네트워크 보안 구성 - http연결

안드로이드가 9 버전(API 28)으로 업데이트를 하면서 http연결이 설정을 해주지 않으면 문제가 생기기 시작했습니다. 그에 따라 해결 방법이 여러 가지가 있는데 그중에서 몇 개를 정리하려 합니다

sskey.tistory.com

 

Topic이 주식 종목명인 경우 주식 현재가도 같이 넣어보겠습니다.

https://finance.naver.com/item/main.naver?code=

 

아래 URL로 검색하면 검색한 종목에 대한 현 시세가 아래와 같은 값으로 리턴됩니다.

 

<div id="middle" class="new_totalinfo"> 
        <dl class="blind"> 
         <dt>
          종목 시세 정보
         </dt> 
         <dd>
          2022년 03월 15일 10시 59분 기준 장중
         </dd> 
         <dd>
          종목명 삼성전자
         </dd> 
         <dd>
          종목코드 005930 코스피
         </dd> 
         <dd>
          현재가 69,700 전일대비 하락 500 마이너스 0.71 퍼센트
         </dd> 
         <dd>
          전일가 70,200
         </dd> 
         <dd>
          시가 69,800
         </dd> 
         <dd>
          고가 70,100
         </dd> 

 

URL이 ARTICLE TYPE이 아닌 STOCK_URL TYPE 에 대해서도 추가 parsing 을 넣어줍니다.

else if(type == Common.STOCK_URL) {
    val contentElements: Elements = doc.select(".new_totalinfo dl")
    if(contentElements.isNotEmpty()) {
        val element1: Element = contentElements[0].select(".blind dd")[1]   //종목명 삼성전자
        val element2: Element = contentElements[0].select(".blind dd")[3]
        //Log.d(Common.MY_TAG, element1.text() + " " + element2.text())
        val stocks: String = element1.text().split(" ")[1]
        mPrice = stocks + " : " + element2.text()//toString()
    }
}

 

아래와 같이 기본적으로 기사들을 horizontal 형태로 보여주고, 주식명인 경우 주식가격도 같이 보여줍니다.

 

 

View는 안드로이드 RecyclerView를 중첩해 하나는 vertical로 하나는 horizontal로 처리해 줍니다.

 

<TopicAdapter>

package com.kakaroo.mynewsfeed

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.kakaroo.mynewsfeed.entity.Topic
import androidx.recyclerview.widget.LinearLayoutManager

class TopicAdapter(private val context: Context, private val listData: ArrayList<Topic>?)
    : RecyclerView.Adapter<TopicAdapter.TopicHolder>() {

    private val mContext: Context = context
    private var mTopicList = listData

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopicHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.topic_item_recycler,parent,false)

        return TopicHolder(view).apply {}
    }

    override fun onBindViewHolder(holder: TopicHolder, position: Int) {
        val topic: Topic? = listData?.get(position)
        if (topic != null) {
            holder.setItem(topic)
        }
        holder.rv_article.adapter = ArticleAdapter(context, mTopicList?.get(position)?.articles)
        holder.rv_article.layoutManager = LinearLayoutManager(
            context,
            LinearLayoutManager.HORIZONTAL,
            false
        )
        holder.rv_article.setHasFixedSize(true)
        holder.tv_topic.text = mTopicList?.get(position)?.title
        holder.tv_topicNum.text = mTopicList?.get(position)?.articles?.size.toString() + " 개"
        holder.tv_stockPrice.text = mTopicList?.get(position)?.price
    }

    override fun getItemCount(): Int = listData?.size ?: 0

    inner class TopicHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val tv_topic: TextView = itemView.findViewById(R.id.tv_topic)
        val tv_topicNum: TextView = itemView.findViewById(R.id.tv_topicNum)
        val tv_stockPrice: TextView = itemView.findViewById(R.id.tv_stock_price)
        val rv_article: RecyclerView = itemView.findViewById(R.id.rv_articles)


        fun setItem(topic: Topic) {
            tv_topic.text = topic.title
            tv_stockPrice.text = topic.price
        }
    }

}

 

<ArticleAdapter> - card 타입으로 horizontal layout

package com.kakaroo.mynewsfeed

import android.app.Activity
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.app.ActivityCompat
import androidx.recyclerview.widget.RecyclerView
import com.kakaroo.mynewsfeed.entity.Article
import androidx.core.content.ContextCompat.startActivity

import android.content.Intent
import android.net.Uri
import androidx.core.content.ContextCompat


class ArticleAdapter(private val context: Context, private val listData: ArrayList<Article>?)
    : RecyclerView.Adapter<ArticleAdapter.CustomViewHolder>() {

    private val mContext: Context = context
    private var mArticleList: ArrayList<Article>? = listData
    //private inflater: LayoutInflater = LayoutInflater.from(context)

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.article_item_recycler, parent, false)

        return CustomViewHolder(view).apply {}
    }

    override fun onBindViewHolder(holder: CustomViewHolder, position: Int) {
        val article: Article? = listData?.get(position)
        if (article != null) {
            holder.setItem(article)
        }
    }

    override fun getItemCount(): Int = listData?.size ?: 0

    inner class CustomViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val tv_date: TextView = itemView.findViewById(R.id.tv_date)
        private val tv_title: TextView = itemView.findViewById(R.id.tv_title)

        fun setItem(article: Article) {
            tv_date.text = article.date.toString()
            tv_title.text = article.title.toString()

            // 아이템 클릭 이벤트 처리.
            itemView.setOnClickListener(View.OnClickListener() {
                val intent = Intent(Intent.ACTION_VIEW, Uri.parse(article.url))
                mContext.startActivity(intent)
            })
        }
    }
}

 

<topic_item_recycler.xml>

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <LinearLayout
        android:orientation="vertical"
        android:background="#FFFFFF"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginBottom="16dp">

        <LinearLayout
            android:orientation="horizontal"
            android:background="#FFFFFF"
            android:layout_weight="1"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <TextView
                android:id="@+id/tv_topic"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="4"
                android:layout_margin="4dp"
                android:textSize="18sp"
                android:textColor="#000000" />

            <TextView
                android:id="@+id/tv_topicNum"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_weight="0.1"
                android:layout_marginTop="10dp"
                android:layout_marginRight="12dp"
                android:textColor="#000000"
                android:textSize="12sp" />
        </LinearLayout>

        <TextView
            android:id="@+id/tv_stock_price"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:layout_margin="0dp"
            android:layout_marginRight="16dp"
            android:textSize="10sp"
            android:textColor="#FF1935D5"/>

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv_articles"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </LinearLayout>

</LinearLayout>

 

<article_item_recycler.xml>

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical" android:layout_width="wrap_content"
    android:layout_height="wrap_content">

        <androidx.cardview.widget.CardView
            android:id="@+id/card_view"
            android:layout_margin="4dp"
            android:layout_gravity="center"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            card_view:cardCornerRadius="4dp">

                <LinearLayout
                    android:orientation="vertical"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:layout_margin="2dp">

                        <TextView
                            android:id="@+id/tv_title"
                            android:layout_width="110dp"
                            android:layout_height="56dp"
                            android:layout_marginLeft="4dp"
                            android:layout_marginTop="4dp"
                            android:layout_marginRight="8dp"
                            android:layout_marginBottom="2dp"
                            android:ellipsize="end"
                            android:maxLines="4"
                            android:text="뉴스 타이틀 블라블라...."
                            android:textSize="12dp" />

                        <TextView
                            android:id="@+id/tv_date"
                            android:layout_width="110dp"
                            android:layout_height="match_parent"
                            android:layout_marginTop="2dp"
                            android:layout_marginRight="4dp"
                            android:layout_marginBottom="4dp"
                            android:ellipsize="marquee"
                            android:singleLine="true"
                            android:layout_gravity="right"
                            android:text="2022-03-14 16:00"
                            android:textSize="8dp"/>


                </LinearLayout>

        </androidx.cardview.widget.CardView>>

</LinearLayout>

 

card view를 위해 아래 dependency를 추가해 줍니다.

implementation "androidx.cardview:cardview:1.0.0"
implementation "androidx.appcompat:appcompat:1.2.0"
implementation "androidx.recyclerview:recyclerview:1.1.0"

 

 

git source : https://github.com/kakarooJ/Android-Kotlin-MyNewsFeed

 

GitHub - kakarooJ/Android-Kotlin-MyNewsFeed

Contribute to kakarooJ/Android-Kotlin-MyNewsFeed development by creating an account on GitHub.

github.com

 

웹서버 포스팅은 설정화면은 만들어놨는데,, 아직 필요성을 못 느껴서 추후, 필요할 때 다시 업데이트 하겠습니다.

 

 


 

만든 화면에 텍스트만 들어가다 보니, 뭔가 밋밋해서 기사 썸네일도 넣어 보았습니다.

 

* 글라이드는 안드로이드에서 이미지를 빠르고 효율적으로 불러올 수 있게 도와주는 라이브러리

implementation 'com.github.bumptech.glide:glide:4.11.0'
annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0'

 

뷰에 이미지 로드하기

특별한 옵션 없이 단순히 뷰에 이미지를 넣는 것이라면 with(), load(), into() 로도 표현할 수 있습니다.

/* Activity에서 사용할 경우 */

Glide.with(this)
    .load(R.drawable.img_file_name)
    .into(imageView)
/* ViewHolder에서 사용할 경우 */

Glide.with(itemView)
    .load(R.drawable.img_file_name)
    .into(itemView.imageView)

 

각 함수의 기능을 간단히 살펴보겠습니다.

  • with() : View, Fragment 혹은 Activity로부터 Context를 가져온다.
  • load() : 이미지를 로드한다. 다양한 방법으로 이미지를 불러올 수 있다. (Bitmap, Drawable, String, Uri, File, ResourId(Int), ByteArray)
  • into() : 이미지를 보여줄 View를 지정한다.

 

위의 함수들은 Glide의 뼈대가 되는 기능입니다. Glide는 단순히 로딩만 도와주는 것이 아니라, 에러 상황이 발생한다던가 후가공이 필요할 때에 손쉽게 처리할 수 있도록 함수를 제공합니다.

예시로, 이미지 로딩 전/후 처리에 대한 함수는 다음과 같습니다.

Glide.with(this)
    .load(R.drawable.img_file_name)
    .placeholder(R.drawable.img_file_place_holder)
    .error(R.drawable.img_file_error)
    .fallback(R.drawable.img_file_no_img)
    .into(imageView)

 

  • placeholder() : Glide 로 이미지 로딩을 시작하기 전에 보여줄 이미지를 설정한다.
  • error() : 리소스를 불러오다가 에러가 발생했을 때 보여줄 이미지를 설정한다.
  • fallback() : load할 url이 null인 경우 등 비어있을 때 보여줄 이미지를 설정한다.

 

Jsoup 을 이용해 thumbnail을 가져옵니다.

namespace tag가 있기 때문에 아래 syntax를 이용해 값을 가져옵니다.

  • ns|tag: find elements by tag in a namespace, e.g. fb|name finds <fb:name> elements

해당 tag 정보

<media:thumbnail url="https://imgnews.pstatic.net/image/thumb140/023/2022/03/15/3678658.jpg"/>

val thumbImg = elem.select("media|thumbnail").attr("url")

 

glide 라이브러리를 이용해 image를 load 해줍니다. img가 없는 기사는 dafault image로 대체해 줍니다.

val imgName = if(article.imgUrl == "") R.drawable.news_thumb_jpg else article.imgUrl

Glide
    .with(itemView.context)
    .load(imgName)   //img drawable
    .centerCrop()
    .placeholder(android.R.drawable.stat_sys_upload)
    .into(img_thumb)
tv_date.text = article.date.toString()
tv_title.text = article.title.toString()

 

Topic 제목 클릭시 Naver 검색화면을, 주식현재가격을 클릭시 Naver 주식검색화면도 소소하나마 넣어보았습니다.

    // 아이템 클릭 이벤트 처리.
    tv_topic.setOnClickListener(View.OnClickListener() {
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(Common.SEARCH_URL_NAVER + topic.title))
        mContext.startActivity(intent)
    })

    tv_stockPrice.setOnClickListener(View.OnClickListener()  { _ ->
        val intent = Intent(Intent.ACTION_VIEW, Uri.parse(Common.STOCK_URL_NAVER + topic.code))
        mContext.startActivity(intent)
    })
}

 


 

UI가 업데이트 되었습니다.

 

edittext 와 recyclerView의 background를 처리해서 UI가 좀 더 돋보이게 업데이트

 

<selector xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- 에디트텍스트가 포커스를 받았을때의 모양 및 색상 -->
    <item android:state_focused="true">
        <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
            <stroke
                android:width="1dp"
                android:color="@color/article_card_view_color_stroke" />
            <corners android:radius="5dp"/>
            <solid
                android:color="@color/article_card_view_color" />
        </shape>
    </item>

    <!-- EditText 가 포커스를 받지 않은 일반 상태일 때의 모양 및 색상 -->
    <item android:state_focused="false">
        <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
            <stroke
                android:width="1dp"
                android:color="@color/article_card_view_color_stroke" />
            <corners android:radius="5dp"/>
            <solid
                android:color="@color/article_card_view_color" />
        </shape>
    </item>
</selector>

 

뉴스 키워드 검색 결과

 

키워드 롱 클릭시 삭제모드

 

차트 보기

반응형