見出し画像

【なんでも自動化してみたい🤖③】 ブログやニュースを自動で収集!!!     LINE + Heroku + GAS

今まで

ニュース記事やブログを探してほしい!!!💔

ということで実装してみます!
構成は

  • 記事を収集: Heroku x  Golang

  • 定期実行:     Google App Script

ということで実装していこうと思います。

記事収集を実装していく🔍

とりあえずこの型に変換していくことからです

type Feed struct {
	Title, Link, Word, Source string
}

Google Newsを漁る


import (
	"fmt"
	"net/url"

	"github.com/mmcdole/gofeed"
	"heroku.dod/domain/interfaces/model"
)

const GOOGLE_NEWS_RSS_URL = "https://news.google.com/rss/search"

func searchGoogleNews(words string) ([]model.Feed, error) {
	url := fmt.Sprintf("%s?q=%s&hl=ja&gl=JP&ceid=JP:ja", GOOGLE_NEWS_RSS_URL, url.QueryEscape(words))
	feeds := []model.Feed{}
	feed, err := gofeed.NewParser().ParseURL(url)
	if err != nil {
		return nil, err
	}

	for idx, item := range feed.Items {
		if idx > 5 {
			break
		}
		feeds = append(feeds, model.Feed{
			Title:  item.Title,
			Link:   item.Link,
			Word:   words,
			Source: "Google News",
		})
	}
	return feeds, nil
}

Noteを漁る


import (
	"fmt"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"sync"

	"github.com/PuerkitoBio/goquery"
	"github.com/corpix/uarand"
	"heroku.dod/domain/interfaces/model"
)

const NOTE_URL = "https://note.com/search?context=note&q=%s&sort=new"

func searchNote(words string) ([]model.Feed, error) {
	url := fmt.Sprintf(NOTE_URL, url.QueryEscape(words))

	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", uarand.GetRandom())

	var mutex sync.Mutex
	mutex.Lock()
	defer mutex.Unlock()
	response, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer response.Body.Close()

	doc, err := goquery.NewDocumentFromResponse(response)
	if err != nil {
		return nil, err
	}

	feeds := []model.Feed{}
	doc.Find("a").Each(func(i int, s *goquery.Selection) {
		link, ok := s.Attr("href")
		if !ok || !strings.Contains(link, "/n/") {
			return
		}

		text, ok := s.Attr("aria-label")
		if !ok || trim(text) == "note" {
			return
		}

		feeds = append(feeds, model.Feed{
			Title:  trim(text),
			Link:   trim("https://note.com" + link),
			Word:   words,
			Source: "Note",
		})
	})

	return feeds, nil
}

func trim(text string) string {
	return regexp.MustCompile("\n| |\t").ReplaceAllString(text, "")
}

Qiitaを漁る

 
import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"time"

	"heroku.dod/domain/interfaces/model"
)

var SearchQiita = searchQiita

func searchQiita(query string) ([]model.Feed, error) {
	type Response []struct {
		RenderedBody   string      `json:"rendered_body"`
		Body           string      `json:"body"`
		Coediting      bool        `json:"coediting"`
		CommentsCount  int         `json:"comments_count"`
		CreatedAt      time.Time   `json:"created_at"`
		Group          interface{} `json:"group"`
		ID             string      `json:"id"`
		LikesCount     int         `json:"likes_count"`
		Private        bool        `json:"private"`
		ReactionsCount int         `json:"reactions_count"`
		Tags           []struct {
			Name     string        `json:"name"`
			Versions []interface{} `json:"versions"`
		} `json:"tags"`
		Title     string    `json:"title"`
		UpdatedAt time.Time `json:"updated_at"`
		URL       string    `json:"url"`
		User      struct {
			Description       string      `json:"description"`
			FacebookID        string      `json:"facebook_id"`
			FolloweesCount    int         `json:"followees_count"`
			FollowersCount    int         `json:"followers_count"`
			GithubLoginName   interface{} `json:"github_login_name"`
			ID                string      `json:"id"`
			ItemsCount        int         `json:"items_count"`
			LinkedinID        string      `json:"linkedin_id"`
			Location          string      `json:"location"`
			Name              string      `json:"name"`
			Organization      string      `json:"organization"`
			PermanentID       int         `json:"permanent_id"`
			ProfileImageURL   string      `json:"profile_image_url"`
			TeamOnly          bool        `json:"team_only"`
			TwitterScreenName interface{} `json:"twitter_screen_name"`
			WebsiteURL        string      `json:"website_url"`
		} `json:"user"`
		PageViewsCount interface{} `json:"page_views_count"`
		TeamMembership interface{} `json:"team_membership"`
	}

	url := fmt.Sprintf("https://qiita.com/api/v2/items?query=%s", url.QueryEscape(query))
	response, err := http.DefaultClient.Get(url)
	if err != nil {
		return nil, err
	}

	defer response.Body.Close()
	byts, err := ioutil.ReadAll(response.Body)
	if err != nil {
		return nil, err
	}

	var typedResponse Response
	err = json.Unmarshal(byts, &typedResponse)
	if err != nil {
		return nil, err
	}

	result := []model.Feed{}
	for _, feed := range typedResponse {
		result = append(result, model.Feed{
			Title:  feed.Title,
			Link:   feed.URL,
			Word:   query,
			Source: "Qiita",
		})
	}

	return result, nil
}

非同期で一気に探しまくる


import (
	"context"
	"sync"

	"github.com/fcfcqloow/go-advance/log"
	"heroku.dod/domain/interfaces"
	"heroku.dod/domain/interfaces/model"
)

type feed struct{}

func NewFeeder() interfaces.FeedDataSource {
	return new(feed)
}
func (*feed) Search(ctx context.Context, words string) ([]model.Feed, error) {
	wg := &sync.WaitGroup{}
	result := []model.Feed{}
	fns := [...](func(string) ([]model.Feed, error)){
		searchGoogleNews,
		searchNote,
		searchQiita,
	}
	for _, fn := range fns {
		fn := fn
		wg.Add(1)
		go func() {
			defer wg.Done()
			f1, err := fn(words)
			if err != nil {
				log.Warn(err.Error())
				return
			}
			result = append(result, f1...)
		}()
	}

	wg.Wait()
	return result, nil
}

定期実行📅

Herokuにアクセス

/**
 * @param  {string} baseUrl
 * @param  {string} apikey
 * @return {HerokuDataSource}
 */
function newHeroku(baseUrl, apikey) {
  return new HerokuDataSource(baseUrl, apikey)
}

class HerokuDataSource {
  constructor(baseUrl, apikey) {
    this._baseUrl = baseUrl;
    this.apikey = apikey;
  }

  searchArticles(words) {
    const headers = {
      'x-api-key': this.apikey,
    };

    const body ={
      words,
    }
    return Http.toJson(Http.post(`${this._baseUrl}/search/articles`, {
      headers, body: JSON.stringify(body),
    }))
  }
}

Lineに通知したい

const APIEndpointBase        = "https://api.line.me";
const APIEndpointPushMessage = "/v2/bot/message/push";

/**
 * @param  {string} token
 * @param  {string} targetGroupId
 * @return {LineStreamer}
 */
function newLine(token, targetGroupId) {
  return new LineStreamer(token, targetGroupId);
}

class LineStreamer {
  constructor(token, targetGroupId) {
    this.token = token;
    this.groupId = targetGroupId;
  }

  /**
   * @param {string} text
   * @param {string} to
   */
  publishTextMessage(text, to) {
    const headers = {
      Authorization  : `Bearer ${this.token}`,
      "Content-Type" : "application/json; charset=UTF-8",
    };
    const body = JSON.stringify({
      to       : to || this.groupId,
      messages : [{ type : 'text', text }],
    });
    return Http.post(`${APIEndpointBase}${APIEndpointPushMessage}`, { body, headers });
  }
}

UseCaseをまとめる

notifyArticle(heroku, line, db) {
    const linkSet = new Set();
    const isDuplicate = (value) => {
      const res = linkSet.has(value);
      linkSet.add(value);
      return res;
    };

    const words = [
      "駆け出しエンジニア",
      "ここにワードを追加していく",
      "ここにワードを追加していく",
      "ここにワードを追加していく",
      "ここにワードを追加していく",
    ]
    .sort(() => Math.random() - 0.5)
    .slice(0, 5);

    const articles = heroku.searchArticles(words)
    .filter(article => !isDuplicate(article.link) && !links.includes(article.link))
    .sort(() => Math.random() - 0.5)
    .slice(0, 15);

    const body = articles.map(article => `【${article.word}${article.source}${article.title}\n${article.link}\n`).join("\n");
    if (body) line.publishTextMessage("今日のお勧め記事\n\n" + body);
  },

GASの参考

まとめ✅

これで自動的に毎日朝昼晩記事を探してくれています!
(実際はスプレットシートに記録して同じ記事はお勧めしないようにしていますが、今回は複雑なので紹介しておりません🙇‍♂️)
とにかく今回の自動化で、情報収集が楽になりました!?

次回は、ラズパイのカメラで監視カメラあたりやりたいかなと思っております🙇‍♂️

この記事が気に入ったらサポートをしてみませんか?