それでも気分は高専生

元高専生が自分のやってきたことを記事として残すためのてきとーなブログ

SolrでConflの記事を検索 - 検索フロントエンド編

検索フロントエンド編

SolrでConflの記事を検索 - それでも気分は高専生の個別実装第三弾です。

前回の記事まではConflの記事を取得し、Solrにインデクシングしてクエリを投げられる状態を作るところまで来ました。

ここまでで検索システムの機能としては十分なのですが、検索結果が生のJSONとなっているため、非常に見辛いです。

そこでフロントエンドのシステムを開発して、使い勝手のいい検索システムを目指しました。

今回フロントエンドに利用したのはReact+TypeScriptです。

TypeScriptでSolrへクエリを投げる

まずTypeScriptからSolrへクエリを投げるところを作ります。

SolrへのリクエストはJSON Request API | Apache Solr Reference Guide 8.1を通じて行います。

JSON APIを使った方がクエリの取り回しが楽だったので....

ファセットの絞り込みがfqじゃなくてqにANDで繋げてしまっているという痛恨のミス...

また、クエリではフィールド毎に繋げる条件を変え、前の方の単語が高く重み付けされるというちょっとした工夫を加えてみました。

const solrEndpoint = "http://localhost:8983/solr/confl/query?sow=true";

const requestTemplate = {
  query: "*",
  filter: new Array<string>(),
  offset: 0,
  limit: 10,
  fields: [
    "id",
    "title",
    "space_name",
    "createdBy_username",
    "createdBy_displayName",
    "view",
    "URL",
  ],
  facet: {
    sort: "count",
    categories: {
      type: "terms",
      field: "labels",
      limit: 10,
    },
  },
};

function filterToCondition(filter: string[]) {
  if (filter.length === 0) {
    return "";
  }
  return " AND " + filter.map((e) => `labels:${e}`).join(" AND ");
}

function weight(idx: number) {
  return 50 * Math.exp(-1 * idx);
}

function textToCondition(query: string) {
  const terms = query.split(" ");
  const title = terms.map((e, i) => `title:${e}^${weight(i)}`).join(" OR ");
  const view = terms.map((e, i) => `view:${e}^${weight(i)}`).join(" AND ");
  const space = terms.map((e, i) => `space:${e}^${weight(i)}`).join(" OR ");
  return `((${title}) OR (${view}) OR (${space}))`;
}

export function Search(
  query: string,
  filter: string[],
  setArticles: React.Dispatch<React.SetStateAction<never[]>>,
  setFacets: React.Dispatch<React.SetStateAction<never[]>>
) {
  const request = requestTemplate;
  request.query = textToCondition(query) + filterToCondition(filter);
  console.log(request.query);
  (async () => {
    await fetch(solrEndpoint, {
      method: "post",
      mode: "cors",
      headers: {
        "Content-Type": "application/json; charset=utf-8",
      },
      cache: "no-cache",
      body: JSON.stringify(request),
    })
      .then((res) => res.json())
      .then((data) => {
        console.log(data);
        let newArticles = data.response.docs.map((e: any) => new Article(e));
        setArticles(newArticles);

        let newFacets =
          data.facets.count === 0
            ? []
            : data.facets.categories.buckets.map((e: any) => new Facet(e));
        setFacets(newFacets);
      })
      .catch(function (error) {
        console.log(error);
      });
  })();
}

ArticleとFacetの定義はこんな感じです。

Articleは各記事を表現するクラスで、FacetはSolrのレスポンスに含まれるファセットを表現します。

ファセットには、インデクシングCLIで付与されたラベル、検索結果のなかにそのラベルが何記事含まれているか、という情報が含まれています。

検索結果でArticleとFacetのリストを表示し、Facetから絞り込む対象のラベルを指定する...というイメージです。

export class Facet {
  public val: string;
  public count: Number;
  public constructor(obj: any) {
    this.val = obj.val;
    this.count = obj.count;
  }
}
export class Article {
  public id: string;
  public url: string;
  public displayName: string;
  public userName: string;
  public spaceName: string;
  public title: string;
  public view: string;
  public constructor(obj: any) {
    this.id = obj.id;
    this.url = obj.URL;
    this.displayName = obj.createdBy_displayName;
    this.userName = obj.createdBy_username;
    this.spaceName = obj.space_name;
    this.title = obj.title;
    this.view = obj.view;
  }
}

検索のアクションをUIに埋め込む

以下のタイミングで検索クエリをSolrに投げてArticleとFacetを更新します。 - Searchボタンが押されたタイミング - filterが変更を受けたタイミング

ここでfilterはユーザがページを訪れて以来、押したファセットを記憶しておくリストになります。

function App() {
  const [articles, setArticles] = useState([]);
  const [facets, setFacets] = useState([]);
  const [filter, setFilter] = useState(new Array<string>());
  const [query, setQuery] = useState("*");

  useEffect(() => {
    Search(query, filter, setArticles, setFacets);
  }, [filter]);

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <div>
          <Input
            className="QueryForm"
            color="primary"
            type="search"
            defaultValue={query}
            onChange={(e) => setQuery(e.target.value)}
          ></Input>
        </div>
        <div>
          <Button
            color="primary"
            variant="contained"
            className="Search"
            onClick={() => {
              Search(query, filter, setArticles, setFacets);
            }}
          >
            Search
          </Button>
        </div>
        <div>
          <Results
            articles={articles}
            facets={facets}
            filter={filter}
            setFilter={setFilter}
          ></Results>
        </div>
      </header>
    </div>
  );
}

export default App;

検索結果を見せる

ここでやることは、いい感じに検索結果を見せることです。

ロジックらしいところはファセットがクリックされた時に、setFilters()を実行してuseEffect()が発火、Search()が実行されるようにしたぐらいです。

また見た目を整えるのにはReact-Material-uiを利用しました。

material-ui.com

type ResultProps = {
  articles: Article[];
  facets: Facet[];
  filter: string[];
  setFilter: React.Dispatch<React.SetStateAction<string[]>>;
};

const newFacetStyle = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: "100%",
      maxWidth: 360,
      backgroundColor: theme.palette.background.paper,
    },
  })
);

const newArticleStyle = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: "100%",
      backgroundColor: theme.palette.background.paper,
    },
  })
);

export function Results(props: ResultProps) {
  const facetStyle = newFacetStyle();
  const articleStyle = newArticleStyle();
  return (
    <div className="Result">
      <Grid
        container
        direction="row"
        justify="flex-start"
        alignItems="flex-start"
      >
        <Grid item xs={2}>
          <List className={facetStyle.root}>
            {props.facets.map((e, i) => (
              <ListItem
                key={i}
                alignItems="flex-start"
                onClick={(event) => props.setFilter(props.filter.concat(e.val))}
              >
                <Button color="primary">
                  {e.val} ({e.count})
                </Button>
              </ListItem>
            ))}
          </List>
        </Grid>
        <Grid item xs={10}>
          <List className={articleStyle.root}>
            {props.articles.map((e) => (
              <ListItem key={e.id} alignItems="flex-start">
                <ListItemText
                  primary={
                    <Link href={e.url}>{`${e.title}: ${e.displayName}`}</Link>
                  }
                  secondary={
                    typeof e.view === "undefined"
                      ? "no contents"
                      : e.view.substring(0, 400) + "..."
                  }
                ></ListItemText>
              </ListItem>
            ))}
          </List>
        </Grid>
      </Grid>
    </div>
  );
}

完成したフロントエンド

以上の流れでフロントエンドを開発しました。 以下が完成したものになります。

f:id:takahiro0914:20200805004932p:plain
Webフロントの完成図

そのうち開発して感じたことや感想を書きます...