はじめに
先日、初めて公開Webサービスを作ってみた。
ブログのSEO対策ツールで、自分のURLと競合ページのURLを入力するとそれぞれのページに含まれるキーワード別の重要度がわかる、というものだ。
これ、このページの下の方に書いた通り、いろんなライブラリの寄せ集めで、ぼくは何も難しいことをしていないんだけど、何しろ初めて公開Webサービスを作ったので、色々試行錯誤があった。
だれもがはじめて作るときは初心者だ。
初心者には初心者なりの悩みがある。
これからWebサービスを作りたいと思っている人に、少しでも参考になればと思って筆をとってみた(キーボードを叩いてみた)。Qiitaにも初投稿だ。
このサービスを作った目的
ぼくはこのサービスのトップドメインでしょうもないブログを書いていた。
コロナになるくらいから始めてはみたものの、ご多分に漏れず全く収益のあがらない、少しだけSEOがわかってきてAdSense収益が月2,000円くらいになったブログだ。
一応、それなりに本気でSEOに取り組む中で、検索でみつけたこのページで専門用語を拾って比較することで、競合ページとの比較・分析をするようになった。
自分より上位の競合ページにあって、自分のページにない単語で重要そうなものを自分のブログに足していく作業だ。
これだと一つ一つURLを打ち込まなければいけないし、単語の有無をスプレッドシートにコピペして調べなければいけない。
これを一発でやりたいと思って自作することにした。
ちゃんと調べてないけど、多分SEOの有料サービスなら似たようなことができるんだろうけど、月10,000円以上はするはずなので、自作することに決めた。
Webサービスの構成
このサービスの構成は以下の通り。
試行錯誤と奮闘の記録
さて、ここからは細かな技術説明をする気はない。(一応、拙いコードは載せる)
そもそもサービスページの一番下にも記載しているが、このサービスは数々の優れたライブラリの寄せ集めであって、解説できるほど高度な技術を使っていない。
ぼくの技術力なんてハナクソである。
数多の優れた解説記事があるQiitaで、わざわざ技術情報を公開するのも恥ずかしくて気が引ける。
これを投稿した理由は、冒頭に書いたようにWebサービス公開の初心者に向けたものだ。
なので、ここからはこのサービスを作るのに使っていたTrelloの完了タスクを思い出しながら、奮闘の記録を綴っていこうと思う。
主に上に書いたサービス構成に至るまでに考えたこと、ぶつかった課題とそれに対する解決策(妥協...)などの記録である。
基本的に実際に構築するまでの時系列で書いていくので、構成要素に対しては行き来するが容赦願いたい。
このサービスの本質部分 - 文章の解析から重要度の判定
ほんとの最初の最初は公開サービスにしようと考えていたわけではなくて、あくまで自分と競合のブログページのキーワード抽出だったので、自分の作業用に、
- Webサイトの本文を抽出
- 形態素解析
- 重要度判定
というツール的なものをPythonで書いていた。
この部分は以下のような感じである。
指定したWebページから本文を抽出
SEO対策なので、Webページの内容を抽出するにあたり、タイトルや他記事の紹介など本文に関係ない部分は除き、本文だけを抽出する必要がある。
これも結構厄介だな、とおもっていたところ調べてみると、extractcontentという、もともとはRubyのライブラリらしいPython版がPyPIで公開されていていた。
すべてのWebページできれいに本文だけ抽出できるということはないのだが、ある程度抽出できるっぽいのでこれを使うことにした。
形態素解析
形態素解析といえばMecabしかない。
PythonのMecabは辞書もライブラリ化されているのでとてもありがたい。
後述するが、サーバレスAPIで実装できたのも辞書がライブラリ化されていたおかげだ。ローカル辞書だけだったらサーバレスAPIでは実現できない(と思う)。
辞書が、ローカルの巨大版と軽量ライブラリ版では品詞体系が異なったりするのだが、誤差の範囲と割り切ってこのサービスでは軽量版を使っている。
文章の重要度判定
重要度判定にはTF-IDFを用いている。
概念的にはそれほど難しいものではなく、ぼくの理解できる範囲なので自作できなくもなかったのだが、これもscikit-learnにライブラリがあったのでそれを利用。
(該当する部分のPython code)
import requests
from extractcontent3 import ExtractContent
from urllib.parse import urlparse
import MeCab
from sklearn.feature_extraction.text import TfidfVectorizer
import pandas
import json
import logging
import warnings
EXTRACTCONTENT_OPTION = {"threshold":100,
# "decay_factor":0.3,
"continuous_factor":0.1,
}
IGNORE_PART_OF_SPEECH_LIST = ('助詞', '助動詞', '補助記号', '記号', '空白', '名詞,数詞', '動詞,非自立可能', 'BOS/EOS,', '接頭辞', '接尾辞', '連体詞', '代名詞', '感動詞', '接続詞', '形容詞,非自立可能', '副詞', '形状詞,助動詞語幹')
def main(urls):
sentences = []
headers = []
for url in urls:
sentence = getText(url)
if sentence:
sentences.append(sentence)
headers.append(urlparse(url).netloc)
if not sentences:
raise ValueError("all URLs are errors. make sure you did not typo the URL. certain sites may not be able to retrieve text.")
vectrizer = TfidfVectorizer(tokenizer = wakatiText, smooth_idf = False, token_pattern = '')
with warnings.catch_warnings():
warnings.simplefilter("ignore")
vectrizer.fit(sentences)
tfidf = vectrizer.transform(sentences)
values = tfidf.toarray()
feature_names = vectrizer.get_feature_names_out()
df = pandas.DataFrame(values, columns = feature_names,
index=headers)
df.loc['gte_0.1_diff'] = False
df.loc['gte_0.1_diff'] = df.loc['gte_0.1_diff'].astype(bool)
for i in range(1,len(df)-1):
df.loc['gte_0.1_diff'] = (df.loc['gte_0.1_diff']) | ((df.iloc[i] - df.iloc[0]) >= 0.1)
return df.T.to_json(orient='table')
extractor = ExtractContent()
extractor.set_option(EXTRACTCONTENT_OPTION)
def getText(url):
try:
headers={'User-Agent': 'Mozilla/5.0'}
res = requests.get(url, headers=headers, timeout=5.0)
except requests.exceptions.SSLError as e:
logging.warning(f'SSLError:{url}:retry non verify mode')
with warnings.catch_warnings():
warnings.simplefilter("ignore")
res = requests.get(url, headers=headers, timeout=5.0, verify=False)
except requests.exceptions.RequestException as e:
logging.error(f'{url}:{e}')
return ''
if res.status_code == requests.codes.ok and 'text/html' in res.headers['content-type']:
res.encoding = res.apparent_encoding
extractor.analyse(res.text)
text, title = extractor.as_text()
if not text:
if 'Sucuri' in res.headers['Server']:
logging.warning(f'because Sucuri site, could not get text. : {url}')
else:
logging.warning(f'no reason why, could not get text. : {url}')
return text
else:
return ''
wakati = MeCab.Tagger()
def wakatiText(text):
node = wakati.parseToNode(text)
word_list = []
while node:
if not node.feature.startswith(IGNORE_PART_OF_SPEECH_LIST):
word_list.append(node.surface)
node = node.next
return word_list
ということで改めて言うが、このサービスは様々な素晴らしい公開ライブラリで成り立っている。
ただそれらを持ってきて並べただけである。
にも関わらずこんなものを書いていて気が引けるのだが、先に進もう。
で、とにかく、複数のWebページをインプットにして重要度の比較結果をアウトプットとする処理が出来上がった。
じゃあ、これを画面で処理できるようにしたら便利だな、それなら公開Webサービスにできるな、と思ったのが最初である。
フロントを何で作るか?
ぼくは公開Webサービスは初めてだけど、Webサイトなら上のブログ以外にも運営経験があった。
(上のブログもAWSでサーバを借りて運用している)
でも、フロントサイドリッチなものじゃなくてdjangoで難しいことが特にない普通のWebサイトだ。
フロントにJSを使わないと今どきのUIにならないので、勉強も兼ねてフロントサイドはVue.jsを採用することにした。
Vue.jsを学ぶのためにUdemiyのこのコースを受講した。
今みたら定価12,000円で結構高いけど(ちなみにUdemyは割引が基本だからこの値段で購入しちゃダメ)、ぼくは所属する会社が学び放題で契約してくれていたのでタダで済んだ。
とてもありがたい。
でもこれを受講することでWebサービスが作れたのだから、中身を知っている身からすると購入の価値はあると思う。このコースは31時間もあって、Vue.jsの基礎からSPAの作り方が学べる。CSSもちゃんと手書きすることでCSSでのデザインの基礎が身についた。(ぼくはCSSが苦手だ)
ちなみに、英語のコースだけど、まあなんとかなる。
なんでもそうだと思うけど、自分の専門領域なら言語が多少違っても映像で理解できるし、Udemyは字幕(このコースは英語の字幕)もあるので問題ないと思う。
本当はURL指定じゃなくてキーワード指定がよかった
はじめは、URLを指定するんじゃなくて、SEOで狙っているキーワードを入力して、中でGoogle検索してトップ5件とかのサイトのページを取得して、最初に書いた処理に渡して解析、ということを考えていた。
なんと言っても出来上がったものはURLをいちいちコピペしなければならず面倒だ。
でも調べてみると、Google検索をプログラムで取得するにはかなり高額。Getting started with Programmable Search Engine
クエリ1,000件につき$5...
ダメじゃん...
極力お金をかけずに構築したいので、これを見た時点でこのサービスはやめようと思った。
ぼくはとてもめんどくさがりなので、URLをコピペしなければいけないUIが微妙だと思ったのだ。
でも、自分のブログページの指定はどちらにしても必要だし、競合ページを任意に指定できるのはそれはそれでニーズがあるかと思い、URL指定でのインプットで妥協することにした。
バックエンドは何で作るか?
フロントエンドをVue.jsにしたので、入力を受け取り分析結果を返すバックエンドはWeb APIにしなければいけない。
上述したように重要度判定の部分はPythonで作ったので、例えばdjango REST frameworkで作ってサーバ借りなきゃいけないのかなと思っていた。
django REST〜を使ったことがあるわけではないので、また覚えるの面倒だな、と思って調べていたところ、今はサーバレスのサービスでよさそうなことがわかってきた。
サーバレスならAWS Lambdaかなとも思ったが、後述するようにこの時点ではVue.jsのフロントエンドもサーバを作らなければいけないと思い込んでいて、それならUdemyのコースが紹介していたFirebaseのあるGoogle Cloudの方が簡単そうだと感じていたので、フロントと同居する可能性を考慮してWeb APIはGoogleのCloud Functionsにした。
上のリンクのドキュメントのクイックスタートでは、httpの、Pythonでのサービスをサンプルコード付きで説明してあるので、SDKのインストールなどしてデプロイまでなんとかこぎつけることができた。
結果の表形式での表示
URLリストの入力を受け取って結果を画面に表示する際には表形式で表示したいと思った。
表形式にするJSのライブラリもあるのかな、なんて調べているとやっぱり色々あることがわかった。(例えばこの記事)
数ある中で、Vue3でも使えてリリースが継続しているtabulatorを選んだ。
ほしいのはjson形式で扱えて、画面でソートできるということだったので機能的に十分なものだった。
Web APIがjsonで返すのが当たり前ということも知らなかったというクソ素人なのだが、APIの返りのjsonをtabulatorの入力にするだけで簡単に表形式で表示することができた。
(tabulator部分のjs code)
import {ref, onMounted} from 'vue';
import {TabulatorFull as Tabulator} from 'tabulator-tables';
export default {
props: ['result'],
setup(props){
const resultText = ref('');
const table = ref(null);
const tabulator = ref(null);
const tableData = ref([]);
const columns = ref([]);
const initialSort = ref([])
const isClearFilter = ref(false)
tableData.value = props.result.data;
const fieldNames = props.result.schema.fields.map(fields => fields.name);
columns.value = fieldNames.map(name => ({title: name, field: name}) );
// 最後のcolumnの"gte_0.1_diff"は表示しない
columns.value[columns.value.length - 1]['visible'] = false;
// 初期ソートは3列目(ライバル記事)以降、最後(最後は自分の記事とのTF-IDF値の差)の手前までを降順
initialSort.value = fieldNames.slice(2, -1).map(name => ({column: name, dir: "desc"}))
initialSort.value.reverse()
onMounted(() => {
tabulator.value = new Tabulator(table.value, {
data: tableData.value,
layout:"fitColumns",
// autoColumns: true,
columns: columns.value,
nestedFieldSeparator:"|", //for url field name contains dot
initialSort: initialSort.value,
columnHeaderSortMulti:true,
height:"100%",
pagination:true,
paginationSize:20,
})
// 初期フィルター切り替え
const checkFilter = props.result.data.filter(x => x["gte_0.1_diff"])
if (checkFilter.length > 0) {
tabulator.value.on("tableBuilt", () => {
tabulator.value.setFilter("gte_0.1_diff", "=", true);
});
isClearFilter.value = false
} else {
isClearFilter.value = true
}
});
/////// 以下省略
サーバどうする問題
フロントのVueで作ったSPAがだいたい出来上がったところで、どこにデプロイするか検討を始めたのだが、2つ大きな勘違いがあった。
そもそもSPAがスタティックファイルなことをわかっていなかったこと
出来上がったSPAを動かすのに、開発するのにインストールしたnode.jsやvue/cliが要るので何かしらサーバ借りなきゃと思い込んでいた。
アホである...
vueをbuildして出来上がったhtml/js/cssは、もうそれだけで動くスタティックなファイルなのでどこにでも置けるのである。
そのことにしばらく気づいていなかった。
データ転送料金も関係なかったこと
スタティックなファイルだとしても、このサービスでは入力されたURLのページをダウンロードするのでデータ転送料金が発生するから、置いた先のサービス料金をよく見ないとな、と思っていたがこれも勘違いだった。
SPA側はURLリストを受け取って、APIに投げて、API側でURLにリクエストをする仕組みなのに、なぜかサーバ側で発生すると思い込んでいた。
ほんとにバカである...
だから、結局ホントにどこでもよかったのだが、ドメインを新規取得するのも面倒だしGoogle AdsenseやAnalyticsのこともあるので、既存のブログサイトのサブページに置くことにした。
何もなかったらAWS S3に置いたと思う。
公開APIとは...
逆にクライアントからWeb APIが発行される公開APIというのは誰でも叩けてしまうから、大量アクセスが発生したりぼくのサイトを経由せずにデータを返すのはよくないなと思い、何かしら対策すべきだと考えたのだが、結局のところ不特定多数の人にWebで公開するという以上、そんなことは無理だということがわかった。
もちろんWAFなどで防ぐということはできるが、そこまでかけるお金はない。
API側でのリファラチェックを入れただけにした。
スクレイピングされる懸念があるが仕方がない...
Cloud Functionsのメモリ問題と利用料金
Web APIをCloud Functions上で最初に動かしたところメモリ不足でエラーになった。
scikit-learnのロードで結構メモリを食うのが原因だったようだが、TF-IDFを自作するのは面倒...
Cloud Functionsのデフォルトが256M?だったのを512Mにして、当初インプットのURL数を10としていたのを5にした。
また、Cloud Functionsの料金体系がメモリとvCPUと実行所要時間で決まるので、URLにリクエストするときのタイムアウトも入れるようにした。
リクエスト数に関しては200万回まで無料なので、よほど問題ないだろう。
ちなみに、Cloud Functionsの料金を調べる中で、ケチなぼくはより安い方がいいからとAWS Lambdaと比較試算してみたのだが、以下の通り(落書きで申し訳ないが)大きくは変わらないと判断した。
Google AdsenseとAnalytics
一応、万が一アクセスがそこそこ集まったらAdsense収入くらいほしいから、Adsenseスクリプトを入れようと思ったが、Vueのtemplate部分にscriptタグは入れられないので(挿入できるライブラリはあるが、むやみにやたらにライブラリを追加するのは好きじゃない)、vueのindex.htmlのheadに自動広告として入れた。
つまり広告の入れる場所は指定できないというわけだ。
まあ、今回はそれでも問題ない。
どれくらいアクセスがあるか知るためにAnalyticsスクリプトも同様にindex.htmlに書いた。
tabulatorのページネーション
このサービスを使う側のUXを考えると、見る単語はだいたいトップ20くらいで最後まで細かく見るわけじゃないと想定される。
だから最初は、単語がどれだけ多くてもページネーションなしで全部1画面にずらーっと出ればいいと思っていた。
(初期表示は競合ページで重要な単語のみを表示して、表示切り替えボタンをクリックすると全単語を表示する仕様)
初期表示分はそれほど多くないのでページネーションなしで問題ないのだが、全単語表示した場合、単語が多すぎるページでは時間がかかって固まってしまった。
ブラウザのスペックに依存するようだ。
tabulatorのページネーションをオンにしたら快適になった。ページネーション機能があって本当によかった。
UTF-8のCSVファイルのExcel文字化け問題
これは細かい話なのだが、処理結果をCSVファイルでダウンロードできる仕様にしていて、そのファイル形式がUTF-8カンマ区切りで、これをMicrosoft Excelでダブルクリックで開くと日本語が文字化けする。
いくらExcelでテキストファイルの取り込みウィザードで開けるからと言ってそんなこと利用者にやらせたくなかったから、BOMをつけることで回避したのだが、あれ、なんとかならないのだろうか?
(UTF-8にBOMなんて要るケースある?)
(jsonをUTF-8カンマ区切りBOM付きに変換してダウンロードリンクを作るところのjs code(json2csvはstack overflowにあったコードを参考))
function json2csv(data) {
const replacer = (key, value) => value === null ? '' : value
const header = Object.keys(data[0])
return [
header.join(','),
...data.map(row => header.map(fieldName => JSON.stringify(row[fieldName], replacer)).join(','))
].join('\r\n')
}
const handleDownloadCsv = () => {
const bom = new Uint8Array([0xEF, 0xBB, 0xBF]);
const url = URL.createObjectURL(new Blob([bom, json2csv(props.result.data)], {type: 'text/plain'}));
const link = document.createElement('a');
link.href = url;
link.innerText = 'Download CSV File';
document.body.appendChild(link);
link.download = 'data.csv'
link.click();
link.parentNode.removeChild(link);
}
いろんなサイトでテストしてみた
実地検証でいろんなWebページをこのサービスにかけてみたところ、いくつか検出した不良があった。いずれもブラウザではアクセスできるのに、requestsを通すとページが取得できないというものである。
ちなみにブラウザ側でjsでページを構成するサイトも取得できないが、これは諦めている。
WebDriverを使えばよいことは知っているが、サーバレス環境で動くかどうかもわからないし明らかにメモリを大量に食いそうなので外している。
その他が以下である。
URLによってはSSLErrorが発生する
PythonのrequestsモジュールはSSLの証明書を認証する際の認証機関をどこにするかをローカルにもっているようだ(間違ってたらすみません)。
で、それがブラウザで持っている認証機関と異なっており、ローカルにない認証機関では通らない証明書のWebページがまれにあるようで、そのようなWebページはエラー(SSLError Exception)になる。
上のPythonソースにあるようにSSLErrorをExceptionで捕捉して、verify=Falseで再実行するようにした。
完全に認証しようとすると、今回の構成のようなサーバレス環境では難しいのかもしれない。(ちゃんとサーバを建てる必要がある)
URLによっては取得できない→スクレイピングをプロテクトするサイトの存在
見つけたのはSucriというスクレイピングをブロックするような対策をしているページ。
ユーザーエージェントを変えてもダメだったので、送信元で判断してクラウド環境のものを一般利用じゃありえないとしてブロックしているのかもしれない。
これは回避しようがなく、判定不能とした。
URLによっては取得できない→原因不明
中にはrequestsが”Read Timed out”になって返ってこない、というページもあった。
stack overflowに英語で頑張って質問を書いてみたのだが、回答なし...
これも諦めざるを得なかった。
最後に
以上が初めて公開Webサービスを作ってみたときの奮闘の記録である。
こんな最後まで読んでくれた方、本当にありがとうございます。
ぼくは本職がSIerで、ブログをAWSで運営してたり技術的にド素人というわけではないのだが、Web系の知識がなくて、試行錯誤の連続だった。
無事公開できてよかった。
(アクセスがそれなりにきたらもっといいけど)
もう一度言うけど、この記録がこれからWebサービスを作りたいと思っている人に少しでも役に立てばうれしい。