전체 텍스트 검색

49205 단어 Node.jsZennGoogletech

개시하다


Zenn의 로드맵에는 책의 전체 텍스트 검색이 있지만 현재 제목과 장과 절의 일치된 검색만 지원합니다
Zenn Roadmap · zenn-dev/zenn-community
어떻게 해야만 전문 검색을 할 수 있습니까?

원고 창고를 직접 검색하다


Zenn은 발언 데이터를 GiitHub 계정과 연결할 수 있습니다.
이에 따라 검색하고자 하는 책의 원고는 기릿허브가 창고를 공개한 경우 기릿허브에서 전문을 검색할 수 있다.

("차크라 UI의 걷기 & 팁 세트"의 창고를 검색한 모습)
그러나 여러 권의 책을 가로질러 검색할 수 없고, 지아이허브 협업이 없거나 창고가 공개되지 않았다면 이 방법을 사용할 수 없다.
이번에 나는 여러 권의 책을 횡단 검색하고 싶어서 바꿀 방법을 찾았다.

내부 API를 사용하여 본문 데이터 클론


Zenn의 로드맵에 따르면 API에서 제공하는 스레드가 있지만 일정은 없습니다.
Zenn과 Zenn이 서명한 나 사이사용 약관에 근거하여 사이트 데이터를 방문하여 이 문제에 대응한다.
역방향 프로젝트의 상세한 정보를 공개하는 것이 목적이 아니기 때문에 정보는 숨겨지지만 사이트 내부 API에서 다음과 같은 정보를 얻을 수 있다
  • 본 일람
  • 책의 장절 목록
  • 장의 텍스트 목록
  • 따라서'텍스트'를 검색해 이 내용을 포함하는'장절','책'을 찾아 여러 권의 책을 횡단으로 찾는 것은 이론적으로 가능하다.
    그러나 일반적으로 호스트 전문 검색 시스템의 비용은 증가할 것이다.쉽게 세울 수 있는 방법은 없을까요?

    Google Drive API를 사용한 검색 인덱스


    Google Drive API는 저장된 파일의 전체 텍스트 검색 기능을 제공합니다.
    Search for files and folders | Google Drive API | Google Developers
    그리고 모든 파일에 속성을 추가할 수 있습니다. 이 키=value 형식의 메타데이터
    Properties | Google Drive API | Google Developers
    이 두 개를 조합해서.
  • 장절의 텍스트를 파일에 저장하고 업로드
  • 속성 위임장 및 장 ID
  • Google Drive API를 통해 모든 파일 업로드 및 검색
  • 검색 결과에서 책과 장의 ID를 얻기
  • 이런 흐름.
    다음은 JavaScript를 사용하는 특정 응용 프로그램 설치에 대한 설명입니다.

    Service Accent 릴리즈


    https://console.cloud.google.com/iam-admin/iam부터 Google Drive API 파일 로그인 및 검색을 위한 서비스 어카운트를 게시합니다.
    그런 다음 https://drive.google.com/drive/my-drive에서 이 서비스 accement에서 읽고 쓸 수 있는 루트 폴더를 만듭니다.Service Accent 가 아닌 자체 Google 계정입니다.
    이 폴더에는 아까 서비스 어카운트에 대한 전자 메일 편집 권한이 부여됩니다.

    이 Service Accent 키를 생성하여 프로그램에서 사용합니다.
    Creating and managing service account keys | Cloud IAM Documentation | Google Cloud

    Google Drive에 업로드


    Google APIs Node.js Client를 사용합니다.
    https://github.com/googleapis/google-api-nodejs-client

    초기화 처리


    const {google} = require('googleapis');
    
    const auth = new google.auth.GoogleAuth({
        credentials: {
            client_email: SERVICE_ACCOUNT_EMAIL,
            private_key: `-----BEGIN PRIVATE KEY-----\n${GOOGLE_PRIVATE_KEY}\n-----END PRIVATE KEY-----`,
        },
        scopes: 'https://www.googleapis.com/auth/drive.file',
    });
    google.options({auth});
    const service = google.drive('v3');
    
    SERVICE_ACCOUNT_EMAIL 및 GOOGLEPRIVATE_KEY는 방금 만든 서비스 어카운트의 열쇠 JSON에 포함되어 있습니다.
    디스크에 작성된 JSON에서도 조립이 가능하지만, 운행 시 상황을 고려해 환경 변수에 숨겨진 데이터를 메모리에 넣어 처리하기 위해 이 같은 설계를 적용했다.

    폴더 생성 함수


    이번 시스템에서는 젠의 책 자체가 워낙 적어 영향은 없지만, 구글 드라이브의 공유 폴더는 1개에 50만 개의 폴더로 제한돼 있다.
    Any single folder in Drive which is not in a  shared drive  can have a maximum of 500,000 items placed within it.
    https://support.google.com/a/answer/2490100
    따라서 이렇게 자동화되면 세척이 안전하다.다음 예에서 하위 폴더를 한 권씩 잘랐습니다.
    async function createFolder(book) {
        const name = book.user.username + '/' + book.slug
        const exist = await service.files.list({
            q: `name = '${name}' and mimeType = 'application/vnd.google-apps.folder' and '${ROOT_FOLDER_ID}' in parents`,
            fields: 'files(id, name)',
        })
        if (exist.data.files.length > 0) {
            await service.files.delete({fileId: exist.data.files[0].id})
        }
    
        const bookFolder = await service.files.create({
            requestBody: {
                name,
                mimeType: 'application/vnd.google-apps.folder',
                parents: [ROOT_FOLDER_ID],
            }
        })
        return bookFolder.data
    }
    
    Google 드라이브 중복 폴더 이름을 허용합니다.같은 책을 여러 번 저장할 때 두 개의 폴더service.files.delete()가 나타나지 않도록 기존 폴더를 미리 삭제한다.

    업로드 함수


    const assert = require('assert');
    
    async function upload(folder, book, chapter, order=0) {
        assert(chapter.allowed, 'Chapter is not allowed')
        assert(chapter.body_html, 'body_html is not defined')
    
        const properties = {
            'order': order,
            'book': book.title.slice(0, 40),
            'username': book.user.username,
            'slug': book.slug,
            'chapter_title': chapter.title,
            'chapter_id': chapter.id,
            'chapter_slug': chapter.slug,
            'cover_url': book.cover_image_small_url
                ? book.cover_image_small_url.replace(/^.*(https:\/\/storage.googleapis.com)/, "$1")
                : 'https://zenn.dev/images/book.png',
            'avatar_url': book.user.avatar_small_url.indexOf('storage.googleapis.com') > -1
                    ? book.user.avatar_small_url.replace(/^.*(https:\/\/storage.googleapis.com)/, "$1")
                    : ''
        }
        
        const description = chapter.body_html.replace(/(<([^>]+)>)/ig, '').slice(0, 4000)
        const res = await service.files.create(
            {
                requestBody: {
                    mimeType: 'application/vnd.google-apps.document',
                    parents: [folder.id],
                    name: book.title + ' - ' + chapter.title,
                    description,
                    createdTime: chapter.body_updated_at,
                    properties,
                },
                media: {
                    mimeType: 'text/html',
                    body: chapter.body_html,
                },
            }
        );
        return res.data
    }
    
    book.title.slice(0, 40)
    book.cover_image_small_url.replace(/.../)
    이렇게 문자 수를 자르는 것은 속성에 UTF-8124바이트 제한이 있기 때문이다.
    또 하나, description에 삭제된 HTML 텍스트를 넣은 것은 검색 결과의 높은 광도를 실현하기 위한 경로입니다.여기도 글자수 제한으로 결말이 줄어들었는데...

    ("("Angular After Tutorial"제1장에 저장된 모습)

    전체 텍스트 검색 함수


    async function searchFromDrive(input, opts={}) {
        const {pageToken, username, slug} = opts
    
        let query = `mimeType = 'application/vnd.google-apps.document' and `
        query += `fullText contains '${input}' and `
        if (username) {
            query += `properties has { key='username' and value='${username}' } and `
        }
        if (slug) {
            query += `properties has { key='slug' and value='${slug}' } and `
        }
        query += 'trashed = false'
        
        const res = await service.files.list({
            q: query,
            pageToken,
            fields: 'files(id, name, parents, description, properties), nextPageToken',
        });
        const books = filesToBooks(res.data.files, input)
        return {books, nextPageToken: res.data.nextPageToken};
    }
    
    흔치 않은 처리이지만 속성 구조를 이용한 검색 조회는 동적 으로 목표 파일로 조립된다.조회의 문법은 아래에 문서가 있다.
    Search for files and folders | Google Drive API | Google Developers
    페이지Token은 이름을 붙이는 데 사용됩니다.filterToBooks()는 응용 프로그램 고유의 성형 처리이기 때문에 생략합니다.
    또한 각 작가의 일람과 책의 제목이 일치하기만 한다면 속성을 물어보기만 하면 된다
    async function searchBooks(input, opts={}) {
        const {pageToken, username} = opts
    
        let query = `mimeType = 'application/vnd.google-apps.document' and `
        query += `name contains '${input}' and `
        if (username) {
            query += `properties has { key='username' and value='${username}' } and `
        }
        query += 'trashed = false'
        
        const res = await service.files.list({
            q: query,
            pageToken,
            orderBy: 'createdTime',
            fields: 'files(id, name, parents, description, properties), nextPageToken',
        });
        const books = filesToBooks(res.data.files, input)
        return {books, nextPageToken: res.data.nextPageToken};
    }
    

    앞머리 해야 돼.


    지금은 노드입니다.js에서 백엔드만 개발한 상태이기 때문에 이번에는 이를 사용하는 웹 응용 인터페이스를 준비했습니다.
    특별히 신경 쓰지 않기 때문에 넥스트.js+Cloud Function으로 구성되어 Firebase Hosting으로 설계되었습니다.

    Cloud Function 섹션


    방금 만든 검색 함수는 **firebase-js-sdk로 브라우저에서 호출할 수 있습니다.
    const functions = require("firebase-functions");
    const {searchBooks, searchChapters} = require("./lib/drive");
    
    exports.search = functions.region('asia-northeast1')
        .https
        .onCall(async (data, context) => {
            const {query, username, slug, pageToken} = data;
            const {auth} = context;
            functions.logger.info(`search: ${query}`, {auth});
            const result = await searchChapters(query, {username, slug, pageToken});
            return result;
        })
    
    exports.searchBooks = functions.region('asia-northeast1')
        .https
        .onCall(async (data, context) => {
                const {query, username, pageToken} = data;
                const {auth} = context;
                functions.logger.info(`search: ${query}`, {auth});
                const result = await searchBooks(query, {username, pageToken});
                return result;
        })
    

    책 검색 UI



    검색이 실행되면 방금 디버깅한 Function이 호출됩니다.
    <form onSubmit={async (event) => {
        event.preventDefault()
    
        const search = await callableAPI('searchBooks')
        const query = event.target.query.value
        const result = await search({query})
        setBooks(result.data.books)
    }}>
        <div className="flex p-2">
            <span className="pr-2">🔍</span>
            <input
                className="pl-2"
                type="text" name={`query`} placeholder="Find Books"/>
        </div>
    </form>
    
    callable API()의 내용
    import {connectFunctionsEmulator, getFunctions, httpsCallable} from "firebase/functions";
    import {initializeFirebaseApp} from "./firebase";
    
    export async function callableAPI(name) {
        const app = await initializeFirebaseApp()
        const functions = getFunctions(app, 'asia-northeast1')
        if (process.env.NEXT_PUBLIC_FUNCTIONS_EMULATOR_HOST) {
            connectFunctionsEmulator(functions, process.env.NEXT_PUBLIC_FUNCTIONS_EMULATOR_HOST, process.env.NEXT_PUBLIC_FUNCTIONS_EMULATOR_PORT);
        }
        return httpsCallable(functions, name)
    }
    
    chapter.body_html는 개발 중인 localhost가 실행 중인 Function을 호출할 수 있는 편리한 도구입니다.

    책의 장절 전문 검색



    ("promise"로 "손으로 React를 쓴 Suspense의 비동기 처리 손잡이"를 검색하는 모습)
    여기서도 클라우드 펀션만 호출했을 뿐 별다른 일은 하지 않았지만, 방금 설명한 description과 일치하는 결과를 bold 글꼴 디스플레이로 바꿔 돋보이는 효과를 나타냈다.
    AND 검색을 지원하기 때문에
    // 雑ハイライト処理
    const headRegex = new RegExp('(' + input.split(/\s/).shift() + ')', 'igm')
    const index = (file.description || ``).search(headRegex)
    const description = index > -1
        ? file.description.slice(index-10, index+90).replace(headRegex, "<strong>$1</strong>")
        : (file.description || ``).slice(0, 100)
    
    connectFunctionsEmulator()를 통과했습니다.
    이상은'Zenn서를 전문으로 검색할 수 있는 사이트'입니다.이 사이트는 보기에 매우 편리한데 다른 사람도 사용할 수 있습니까?

    문제점


    그러나 일반적으로 이 사이트를 공개하는 상황에서 데이터의 업데이트에 대해 짧은 시간에 복제를 반복하지 않으면 낡은 정보가 참조되는 문제가 있을 수 있다(이것도 일반적인 복제 개발에 존재하는 문제점이다).
    예를 들어'자유영 때 무료로 공개했다가 다음 날 비공개했다','책 자체가 삭제됐다'등의 경우에는 적절히 대응해야 한다.
    사용규약에서 금지된'요금 내용을 지급하지 않고 방문하는 행위'는 현금을 이용한 2차 배포를 통해 제3자에게 제공될 위험이 있다.
    제 4 조 (금지사항)
    어떤 수단을 통해 본 서비스의 요금 내용을 지불하지 않은 상태에서 방문하는 행위
    https://zenn.dev/terms
    이런 것들은 모든 책을 대상으로 해야 하기 때문에 지연되지 않고 진행하기 어렵고,'삭제 신청 기능 추가','수동으로 다시 얻을 수 있는 버튼 추가'등의 옵션도 고려할 수 있지만 모두 문제를 빙빙 돌려서 한다.
    또한 젠 내부 API를 사용했기 때문에 규격 변경에 따라 파영이 중단될 가능성이 높다.
    그래서 나는 이 사이트를 개인이 사용하는 범위 내에 두기로 결정했다.

    끝말


    Google Drive API를 사용하면 간단한 전체 텍스트 검색 기능을 갖춘 애플리케이션을 쉽게 개발할 수 있습니다.
    이번 밀폐된 환경에서는'젠의 책을 전문으로 검색하고 싶다'는 목적에 도달했으나 더는 공개적으로 사용하기 어려워 포기했다.
    공식적으로 기능이 제공되거나 Zenn API가 공개될 때까지 기다리겠습니다.

    좋은 웹페이지 즐겨찾기