自前AI検索ツールの文脈で、searxngと並んでduckduckgoという検索エンジンはよく実装に入っているのを見る。
langchain系の実装のGolang版でlangchaingoというのがあるのだが、まさにそのライブラリでduckduckgoを使っていて興味が湧いた。
自分でも業務特化の簡易AI検索エンジン的なものを考えている最中で、多少スクレイプというか検索エンジン利用の素振りを試しにしてみたいと思っていた。
スクレイプ自体はあんまり公言するような分野じゃないけど、手札がないよりはあったいい、というくらいの温度感でもあるのでちょっとスクラップ代わりに実験を載せます。
まずはClientラッパーづくり。
package dggo
import (
"context"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/hashicorp/go-retryablehttp"
"github.com/wano/contextlog/clog"
"golang.org/x/xerrors"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
var (
ErrNoGoodResult = xerrors.New("no good search results found")
ErrAPIResponse = xerrors.New("duckduckgo api responded with error")
)
// Client defines an HTTP client for communicating with duckduckgo.
type Client struct {
maxResults int
userAgent string
}
// Result defines a search query result type.
type Result struct {
Title string
Info string
Ref string
OriginalUrl string
}
func New(maxResults int, userAgent string) *Client {
if maxResults == 0 {
maxResults = 1
}
return &Client{
maxResults: maxResults,
userAgent: userAgent,
}
}
func (client *Client) newRequest(ctx context.Context, queryURL string) (*http.Request, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, queryURL, nil)
if err != nil {
return nil, fmt.Errorf("creating duckduckgo request: %w", err)
}
if client.userAgent != "" {
request.Header.Add("User-Agent", client.userAgent)
}
return request, nil
}
// Search performs a search query and returns
// the result as string and an error if any.
func (client *Client) Search(ctx context.Context, query string) ([]Result, error) {
queryURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s&kl=jp-jp", url.QueryEscape(query))
request, err := client.newRequest(ctx, queryURL)
if err != nil {
return nil, err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("get %s error: %w", queryURL, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, ErrAPIResponse
}
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
return nil, fmt.Errorf("new document error: %w", err)
}
results := []Result{}
sel := doc.Find(".web-result")
for i := range sel.Nodes {
// Break loop once required amount of results are add
if client.maxResults == len(results) {
break
}
node := sel.Eq(i)
titleNode := node.Find(".result__a")
info := node.Find(".result__snippet").Text()
title := titleNode.Text()
ref := ""
if len(titleNode.Nodes) > 0 && len(titleNode.Nodes[0].Attr) > 2 {
ref, err = url.QueryUnescape(
strings.TrimPrefix(
titleNode.Nodes[0].Attr[2].Val,
"/l/?kh=-1&uddg=",
),
)
if err != nil {
return nil, err
}
}
// DuckDuckGoの検索結果のURLを取得
originalUrl := ``
{
duckUrl := fmt.Sprintf("http:%s", ref)
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = 5
retryClient.Logger = nil
standardClient := retryClient.StandardClient() // *http.Client
response, err := standardClient.Get(duckUrl)
if err != nil {
clog.Panic(err)
}
defer response.Body.Close()
/*
<html><head><meta name="referrer" content="origin"/></head><body><script language="JavaScript">window.parent.location.replace("https://www.tunecore.co.jp/artists/LOGIC-69STAR");</script><noscript><META http-equiv='refresh' content="0;URL=https://www.tunecore.co.jp/artists/LOGIC-69STAR"></noscript>
</body></html>
*/
// リダイレクト先を取得
doc, err := goquery.NewDocumentFromReader(response.Body)
if err != nil {
clog.Panic(err)
}
scriptTag := doc.Find("body").Find("script").Text()
r := regexp.MustCompile(`"([^"]*)"`)
matches := r.FindStringSubmatch(scriptTag)
if len(matches) > 1 {
originalUrl = matches[1]
}
}
if originalUrl == `` {
continue
}
results = append(results, Result{
Title: title,
Info: info,
Ref: ref,
OriginalUrl: originalUrl,
})
}
return results, nil
}
func (client *Client) SearchAPI(ctx context.Context, query string) ([]Result, error) {
queryURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&pretty=1&kl=jp-jp", url.QueryEscape(query))
//clog.Info(queryURL)
request, err := client.newRequest(ctx, queryURL)
if err != nil {
return nil, err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("get %s error: %w", queryURL, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, ErrAPIResponse
}
bodyAsBytes, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
body := string(bodyAsBytes)
fmt.Println(body)
return nil, nil
}
func (client *Client) SetMaxResults(n int) {
client.maxResults = n
}
// formatResults will return a structured string with the results.
func (client *Client) FormatResults(results []Result) string {
formattedResults := ""
for _, result := range results {
formattedResults += fmt.Sprintf("Title: %s\nDescription: %s\nURL: %s\n\n", result.Title, result.Info, result.Ref)
}
return formattedResults
}
できたので、上記のラッパーを使ってみよう。
以下はある楽曲の「アーティスト名 + 楽曲名」で検索して検索結果を取得しているところです。
package main
import (
"context"
"fmt"
"github.com/sanity-io/litter"
"github.com/tmc/langchaingo/tools/duckduckgo"
"github.com/wano/contextlog/clog"
"github.com/wano/div4-fushimi-playground/project/bedrock-go-01/src/dggo"
)
func main() {
dg, err := duckduckgo.New(3, duckduckgo.DefaultUserAgent)
if err != nil {
clog.Panic(err)
}
searchWord := fmt.Sprintf("%s", "こっちのけんと はいよろこんで")
resp, err := dg.Call(context.Background(), searchWord)
if err != nil {
clog.Panic(err)
}
_ = resp
fmt.Println(`==============================================================`)
x := dggo.New(12, "github.com/tmc/langchaingo/tools/duckduckgo")
resJp, err := x.Search(context.Background(), searchWord)
if err != nil {
clog.Panic(err)
}
litter.Dump(resJp)
}
go run main.go
結果
[]dggo.Result{
dggo.Result{
Title: "はいよろこんで / こっちのけんと MV - YouTube",
Info: "とても辛いことですが、ここで一歩を踏み出して嫌だったことを思い出して、周りが気づくくらい泣き喚くのもグッジョブだと思いま...",
Ref: "//duckduckgo.com/l/?uddg=https://www.youtube.com/watch?v=jzi6RNVEOtA&rut=8a7d7908ef1c05149650d4eb06086f0b7ff5f7771f34da52663dd9ee4516e44d",
OriginalUrl: "https://www.youtube.com/watch?v=jzi6RNVEOtA",
},
dggo.Result{
Title: "こっちのけんと - はいよろこんで / THE FIRST TAKE - YouTube",
Info: "The song has become a national and international sensation, amassing over 8 billion cumulative plays on social media. Enjoy the one-shot performance of a unique rendition of this powerful song ...",
Ref: "//duckduckgo.com/l/?uddg=https://www.youtube.com/watch?v=5BSSPF3i2Vk&rut=b49308c9156f66c78b5b976f41ee51c18680a8a1dde6fbfa214d22eec3d726cc",
OriginalUrl: "https://www.youtube.com/watch?v=5BSSPF3i2Vk",
},
dggo.Result{
Title: "こっちのけんと はいよろこんで 歌詞 - 歌ネット",
Info: "こっちのけんとの「はいよろこんで」歌詞ページです。作詞:こっちのけんと,作曲:こっちのけんと・GRP。(歌いだし)はい喜んであなた方の 歌ネットは無料の歌詞検索サービスです。",
Ref: "//duckduckgo.com/l/?uddg=https://www.uta-net.com/song/359864/&rut=57ce213d99407a669609489b3a1466d2e2f3e17d47e54535b6feaf4e46cae5a3",
OriginalUrl: "https://www.uta-net.com/song/359864/",
},
dggo.Result{
Title: "はいよろこんで 歌詞 こっちのけんと ふりがな付 - うたてん",
Info: "Tik Tokで話題沸騰中の、こっちのけんと「はい よろこんで」。アップテンポなメロディーの裏側に隠された歌詞の意味とは?早速考察していきます。",
Ref: "//duckduckgo.com/l/?uddg=https://utaten.com/lyric/mi24061945/&rut=df74b880e76a574fbf75b71446b8f2296fbf90df75a2a7f327a7c82180774340",
OriginalUrl: "https://utaten.com/lyric/mi24061945/",
},
dggo.Result{
Title: "こっちのけんと「この言葉のおかげで今の僕があります ...",
Info: "こっちのけんとさん(28)の楽曲『はいよろこんで』が、5日に発表された今年1年の世相を言葉で切り取る、現代用語の基礎知識 選『2024 ...",
Ref: "//duckduckgo.com/l/?uddg=https://news.yahoo.co.jp/articles/1077c9733f5186c5053c75cbcc72676611cfb680&rut=d94152be7df1eed032ade733ac70d0a513732de7c2a907df335dc4ac5407feb2",
OriginalUrl: "https://news.yahoo.co.jp/articles/1077c9733f5186c5053c75cbcc72676611cfb680",
},
dggo.Result{
Title: "こっちのけんと はいよろこんで 歌詞&動画視聴 - 歌ネット",
Info: "こっちのけんとの「はいよろこんで」動画視聴ページです。. 歌詞と動画を見ることができます。. (歌いだし)はい喜んであなた方の 歌ネットは無料の歌詞検索サービスです。.",
Ref: "//duckduckgo.com/l/?uddg=https://www.uta-net.com/movie/359864/&rut=062e9dd7cdc665660913fa7d0b6d710fed0f8cc3ae2a84fc367b3719cdf20022",
OriginalUrl: "https://www.uta-net.com/movie/359864/",
},
dggo.Result{
Title: "こっちのけんと「はい よろこんで」の歌詞の意味を考察!生き ...",
Info: "『 はい よろこんで 』は、こっちのけんとの6枚目のシングル。 すべての生きづらい人に向けた曲で、多くの共感を得ている楽曲 です。 こっちのけんと-はい よろこんで【OFFICIAL MUSIC VIDEO】",
Ref: "//duckduckgo.com/l/?uddg=https://utaten.com/specialArticle/index/8690&rut=1c70968d557ea87a03ff9cb418101eec96a2f5f1ae1e9d47a452c58c7888566c",
OriginalUrl: "https://utaten.com/specialArticle/index/8690",
},
dggo.Result{
Title: "【公式】1時間耐久 - はいよろこんで / こっちのけんと - YouTube",
Info: "一般席:6,800円(tax in.) VIP席:11,000円(tax in.)前方席確定+サイン入りVIP限定グッズ付き 行き帰り 楽しい 全部フォローお願いします。. 頑張って ...",
Ref: "//duckduckgo.com/l/?uddg=https://www.youtube.com/watch?v=hY1IbLEjOpM&rut=87a9c052cfb75bb001b5645e330c0a8b9fb32ff367b200464dd997d23ef1bade",
OriginalUrl: "https://www.youtube.com/watch?v=hY1IbLEjOpM",
},
dggo.Result{
Title: "こっちのけんと (Kocchi no Kento) - はいよろこんで (Hai yorokonde)",
Info: "はいよろこんで (Hai yorokonde) Lyrics: 『はい喜んで』 / 『あなた方のため』 / 『はい謹んで』 / 『あなた方のため (Hey)に (Hey)』 / 差し伸ひ ...",
Ref: "//duckduckgo.com/l/?uddg=https://genius.com/Kocchi-no-kento-hai-yorokonde-lyrics&rut=88d0ff957a34cf78d2f7231454c93aa4f9dfce338a0d80bc2b5008f8fe923bae",
OriginalUrl: "https://genius.com/Kocchi-no-kento-hai-yorokonde-lyrics",
},
dggo.Result{
Title: "はいよろこんで-歌詞-こっちのけんと-kkbox",
Info: "はいよろこんで-歌詞-『はい喜んで』 『あなた方のため』 『はい謹んで』 『あなた方のために』 差し伸びてきた手 さながら正義仕立て 嫌嫌て... -今すぐKKBOXを使って好きなだけ聞きましょう。.",
Ref: "//duckduckgo.com/l/?uddg=https://www.kkbox.com/jp/ja/song/8pxGgDIr2c2ekI69Im&rut=969eaf880aaff75ab963977791db1193a0b118749d2ff55601cc93a29d78e96e",
OriginalUrl: "https://www.kkbox.com/jp/ja/song/8pxGgDIr2c2ekI69Im",
},
dggo.Result{
Title: "菅田将暉の実弟・こっちのけんと 総再生数50億回超えの楽曲 ...",
Info: "菅田将暉の実弟・こっちのけんと 総再生数50億回超えの楽曲『はいよろこんで』に込めた思い. 俳優・ 菅田将暉 さんの実弟で、楽曲『はい ...",
Ref: "//duckduckgo.com/l/?uddg=https://news.yahoo.co.jp/articles/4a6e909658bc7180cd021760b40d181970bcea30&rut=666d1a368eca4f514be69d78253ab1d548115852674e97def1300545442eaaf8",
OriginalUrl: "https://news.yahoo.co.jp/articles/4a6e909658bc7180cd021760b40d181970bcea30",
},
dggo.Result{
Title: "こっちのけんと「はいよろこんで」ミュージックビデオ ...",
Info: "こっちのけんと「はいよろこんで」ミュージックビデオ [記事に戻る] 前へ 次へ この記事の画像・動画(全4件) この動画のほかの記事 再生数急 ...",
Ref: "//duckduckgo.com/l/?uddg=https://natalie.mu/music/gallery/news/598170/media/107046&rut=fb33cecf00f413230eefc3b807e7ef7154496bc4572f11b861f77240088e3507",
OriginalUrl: "https://natalie.mu/music/gallery/news/598170/media/107046",
},
}
なるほど。
これ以上のメタタグとかは取得できたURLで普通にスクレイプする流れになりそう。