diff --git a/api/birdxplorer_api/openapi_doc.py b/api/birdxplorer_api/openapi_doc.py index cb790ab..ce36f0b 100644 --- a/api/birdxplorer_api/openapi_doc.py +++ b/api/birdxplorer_api/openapi_doc.py @@ -496,3 +496,98 @@ class FastAPIEndpointDocs(Generic[_KEY]): "participant_id": v1_data_user_enrollments_participant_id, }, ) + +v1_data_x_user_name = FastAPIEndpointParamDocs( + description="Xのユーザー名", + openapi_examples={ + "single": { + "summary": "@以降のユーザ名", + "value": "elonmusk", + }, + }, +) + +v1_data_x_user_follower_count = FastAPIEndpointParamDocs( + description="Xのユーザーのフォロワー数。", + openapi_examples={ + "single": { + "summary": "フォロワー数", + "value": 100, + }, + }, +) + +v1_data_x_user_follow_count = FastAPIEndpointParamDocs( + description="Xのユーザーのフォロー数。", + openapi_examples={ + "single": { + "summary": "フォロー数", + "value": 100, + }, + }, +) + +v1_data_post_favorite_count = FastAPIEndpointParamDocs( + description="Postのお気に入り数。", + openapi_examples={ + "single": { + "summary": "お気に入り数", + "value": 100, + }, + }, +) + +v1_data_post_repost_count = FastAPIEndpointParamDocs( + description="Postのリポスト数。", + openapi_examples={ + "single": { + "summary": "リポスト数", + "value": 100, + }, + }, +) + +v1_data_post_impression_count = FastAPIEndpointParamDocs( + description="Postのインプレッション数。", + openapi_examples={ + "single": { + "summary": "インプレッション数", + "value": 100, + }, + }, +) + +v1_data_post_includes_media = FastAPIEndpointParamDocs( + description="メディア情報を含んでいるか。", + openapi_examples={ + "single": { + "summary": "メディア情報を含める", + "value": True, + }, + }, +) + +# Get /api/v1/data/search の OpenAPI ドキュメント +V1DataSearchDocs = FastAPIEndpointDocs( + "アドバンスドサーチでデータを取得するエンドポイント", + { + "note_includes_text": v1_data_notes_search_text, + "note_excludes_text": v1_data_notes_search_text, + "post_includes_text": v1_data_posts_search_text, + "post_excludes_text": v1_data_posts_search_text, + "language": v1_data_notes_language, + "topic_ids": v1_date_notes_topic_ids, + "note_status": v1_data_notes_current_status, + "note_created_at_from": v1_data_notes_created_at_from, + "note_created_at_to": v1_data_notes_created_at_to, + "x_user_name": v1_data_x_user_name, + "x_user_followers_count_from": v1_data_x_user_follower_count, + "x_user_follow_count_from": v1_data_x_user_follow_count, + "post_favorite_count_from": v1_data_post_favorite_count, + "post_repost_count_from": v1_data_post_repost_count, + "post_impression_count_from": v1_data_post_impression_count, + "post_includes_media": v1_data_post_includes_media, + "offset": v1_data_posts_offset, + "limit": v1_data_posts_limit, + }, +) diff --git a/api/birdxplorer_api/routers/data.py b/api/birdxplorer_api/routers/data.py index 6c48dac..1b48fa6 100644 --- a/api/birdxplorer_api/routers/data.py +++ b/api/birdxplorer_api/routers/data.py @@ -10,6 +10,7 @@ from birdxplorer_api.openapi_doc import ( V1DataNotesDocs, V1DataPostsDocs, + V1DataSearchDocs, V1DataTopicsDocs, V1DataUserEnrollmentsDocs, ) @@ -22,6 +23,7 @@ ParticipantId, Post, PostId, + SummaryString, Topic, TopicId, TwitterTimestamp, @@ -54,6 +56,18 @@ ), ] +SearchPaginationMetaWithExamples: TypeAlias = Annotated[ + PaginationMeta, + PydanticField( + description="ページネーション用情報。 リクエスト時に指定した offset / limit の値に応じて、次のページや前のページのリクエスト用 URL が設定される。", + json_schema_extra={ + "examples": [ + {"next": "http://birdxplorer.onrender.com/api/v1/data/search?offset=100&limit=100", "prev": "null"} + ] + }, + ), +] + TopicListWithExamples: TypeAlias = Annotated[ List[Topic], PydanticField( @@ -141,6 +155,92 @@ ] +class SearchedNote(BaseModel): + noteId: NoteId + summary: Annotated[SummaryString, PydanticField(description="コミュニティノートの本文")] + language: Annotated[LanguageIdentifier, PydanticField(description="コミュニティノートの言語")] + topics: Annotated[List[Topic], PydanticField(description="コミュニティノートに関連付けられたトピックのリスト")] + postId: PostId + current_status: Annotated[ + Annotated[ + str, + PydanticField( + json_schema_extra={ + "enum": ["NEEDS_MORE_RATINGS", "CURRENTLY_RATED_HELPFUL", "CURRENTLY_RATED_NOT_HELPFUL"] + }, + ), + ] + | None, + PydanticField( + description="コミュニティノートの現在の評価状態", + ), + ] + created_at: Annotated[ + TwitterTimestamp, PydanticField(description="コミュニティノートの作成日時 (ミリ秒単位の UNIX EPOCH TIMESTAMP)") + ] + post: Annotated[Post, PydanticField(description="コミュニティノートに関連付けられた Post の情報")] + + +SearchWithExamples: TypeAlias = Annotated[ + List[SearchedNote], + PydanticField( + description="検索結果のノートのリスト", + json_schema_extra={ + "examples": [ + { + "noteId": "1845672983001710655", + "language": "ja", + "topics": [ + { + "topicId": 26, + "label": {"ja": "セキュリティ上の脅威", "en": "security threat"}, + "referenceCount": 0, + }, + {"topicId": 47, "label": {"ja": "検閲", "en": "Censorship"}, "referenceCount": 0}, + {"topicId": 51, "label": {"ja": "テクノロジー", "en": "technology"}, "referenceCount": 0}, + ], + "summary": "Content Security Policyは情報の持ち出しを防止する仕組みではありません。コンテンツインジェクションの脆弱性のリスクを軽減する仕組みです。適切なContent Security Policyがレスポンスヘッダーに設定されている場合でも、外部への通信をブロックできない点に注意が必要です。 Content Security Policy Level 3 https://w3c.github.io/webappsec-csp/", # noqa: E501 + "currentStatus": "NEEDS_MORE_RATINGS", + "createdAt": 1728877704750, + "post": { + "postId": "1846718284369912064", + "xUserId": "90954365", + "xUser": { + "userId": "90954365", + "name": "earthquakejapan", + "profileImage": "https://pbs.twimg.com/profile_images/1638600342/japan_rel96_normal.jpg", + "followersCount": 162934, + "followingCount": 6, + }, + "text": "今後48時間以内に日本ではマグニチュード6.0の地震が発生する可能性があります。地図をご覧ください。(10月17日~10月18日) - https://t.co/nuyiVdM4FW https://t.co/Xd6U9XkpbL", # noqa: E501 + "mediaDetails": [ + { + "mediaKey": "3_1846718279236177920-1846718284369912064", + "type": "photo", + "url": "https://pbs.twimg.com/media/GaDcfZoX0AAko2-.jpg", + "width": 900, + "height": 738, + } + ], + "createdAt": 1729094524000, + "likeCount": 451, + "repostCount": 104, + "impressionCount": 82378, + "links": [ + { + "linkId": "9c139b99-8111-e4f0-ad41-fc9e40d08722", + "url": "https://www.quakeprediction.com/Earthquake%20Forecast%20Japan.html", + } + ], + "link": "https://x.com/earthquakejapan/status/1846718284369912064", + }, + }, + ] + }, + ), +] + + class TopicListResponse(BaseModel): data: TopicListWithExamples @@ -155,6 +255,11 @@ class PostListResponse(BaseModel): meta: PostsPaginationMetaWithExamples +class SearchResponse(BaseModel): + data: SearchWithExamples + meta: SearchPaginationMetaWithExamples + + def str_to_twitter_timestamp(s: str) -> TwitterTimestamp: try: return TwitterTimestamp.from_int(int(s)) @@ -310,4 +415,94 @@ def get_posts( return PostListResponse(data=posts, meta=PaginationMeta(next=next_url, prev=prev_url)) + @router.get("/search", description=V1DataSearchDocs.description, response_model=SearchResponse) + def search( + note_includes_text: Union[None, str] = Query(default=None, **V1DataSearchDocs.params["note_includes_text"]), + note_excludes_text: Union[None, str] = Query(default=None, **V1DataSearchDocs.params["note_excludes_text"]), + post_includes_text: Union[None, str] = Query(default=None, **V1DataSearchDocs.params["post_includes_text"]), + post_excludes_text: Union[None, str] = Query(default=None, **V1DataSearchDocs.params["post_excludes_text"]), + language: Union[LanguageIdentifier, None] = Query(default=None, **V1DataSearchDocs.params["language"]), + topic_ids: Union[List[TopicId], None] = Query(default=None, **V1DataSearchDocs.params["topic_ids"]), + note_status: Union[None, List[str]] = Query(default=None, **V1DataSearchDocs.params["note_status"]), + note_created_at_from: Union[None, TwitterTimestamp, str] = Query( + default=None, **V1DataSearchDocs.params["note_created_at_from"] + ), + note_created_at_to: Union[None, TwitterTimestamp, str] = Query( + default=None, **V1DataSearchDocs.params["note_created_at_to"] + ), + x_user_names: Union[List[str], None] = Query(default=None, **V1DataSearchDocs.params["x_user_name"]), + x_user_followers_count_from: Union[None, int] = Query( + default=None, **V1DataSearchDocs.params["x_user_followers_count_from"] + ), + x_user_follow_count_from: Union[None, int] = Query( + default=None, **V1DataSearchDocs.params["x_user_follow_count_from"] + ), + post_favorite_count_from: Union[None, int] = Query( + default=None, **V1DataSearchDocs.params["post_favorite_count_from"] + ), + post_repost_count_from: Union[None, int] = Query( + default=None, **V1DataSearchDocs.params["post_repost_count_from"] + ), + post_impression_count_from: Union[None, int] = Query( + default=None, **V1DataSearchDocs.params["post_impression_count_from"] + ), + post_includes_media: bool = Query(default=True, **V1DataSearchDocs.params["post_includes_media"]), + offset: int = Query(default=0, ge=0, **V1DataSearchDocs.params["offset"]), + limit: int = Query(default=100, gt=0, le=1000, **V1DataSearchDocs.params["limit"]), + ) -> SearchResponse: + return SearchResponse( + data=[ + SearchedNote( + noteId="1845672983001710655", + language="ja", + topics=[ + { + "topicId": 26, + "label": {"ja": "セキュリティ上の脅威", "en": "security threat"}, + "referenceCount": 0, + }, + {"topicId": 47, "label": {"ja": "検閲", "en": "Censorship"}, "referenceCount": 0}, + {"topicId": 51, "label": {"ja": "テクノロジー", "en": "technology"}, "referenceCount": 0}, + ], + postId="1846718284369912064", + summary="Content Security Policyは情報の持ち出しを防止する仕組みではありません。コンテンツインジェクションの脆弱性のリスクを軽減する仕組みです。適切なContent Security Policyがレスポンスヘッダーに設定されている場合でも、外部への通信をブロックできない点に注意が必要です。 Content Security Policy Level 3 https://w3c.github.io/webappsec-csp/", # noqa: E501 + current_status="NEEDS_MORE_RATINGS", + created_at=1728877704750, + post={ + "postId": "1846718284369912064", + "xUserId": "90954365", + "xUser": { + "userId": "90954365", + "name": "earthquakejapan", + "profileImage": "https://pbs.twimg.com/profile_images/1638600342/japan_rel96_normal.jpg", + "followersCount": 162934, + "followingCount": 6, + }, + "text": "今後48時間以内に日本ではマグニチュード6.0の地震が発生する可能性があります。地図をご覧ください。", + "mediaDetails": [ + { + "mediaKey": "3_1846718279236177920-1846718284369912064", + "type": "photo", + "url": "https://pbs.twimg.com/media/GaDcfZoX0AAko2-.jpg", + "width": 900, + "height": 738, + } + ], + "createdAt": 1729094524000, + "likeCount": 451, + "repostCount": 104, + "impressionCount": 82378, + "links": [ + { + "linkId": "9c139b99-8111-e4f0-ad41-fc9e40d08722", + "url": "https://www.quakeprediction.com/Earthquake%20Forecast%20Japan.html", + } + ], + "link": "https://x.com/earthquakejapan/status/1846718284369912064", + }, + ) + ], + meta=PaginationMeta(next=None, prev=None), + ) + return router diff --git a/common/birdxplorer_common/models.py b/common/birdxplorer_common/models.py index 060949a..02492ad 100644 --- a/common/birdxplorer_common/models.py +++ b/common/birdxplorer_common/models.py @@ -30,7 +30,7 @@ from pydantic.alias_generators import to_camel from pydantic.json_schema import JsonSchemaValue from pydantic.main import IncEx -from pydantic_core import Url, core_schema +from pydantic_core import core_schema StrT = TypeVar("StrT", bound="BaseString") IntT = TypeVar("IntT", bound="BaseInt") @@ -799,11 +799,11 @@ class Link(BaseModel): t.co に短縮される前の URL ごとに一意な ID を持つ。 >>> Link.model_validate_json('{"linkId": "d5d15194-6574-0c01-8f6f-15abd72b2cf6", "url": "https://example.com"}') - Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=Url('https://example.com/')) + Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=HttpUrl('https://example.com/')) >>> Link(url="https://example.com/") - Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=Url('https://example.com/')) + Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=HttpUrl('https://example.com/')) >>> Link(link_id=UUID("d5d15194-6574-0c01-8f6f-15abd72b2cf6"), url="https://example.com/") - Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=Url('https://example.com/')) + Link(link_id=LinkId('d5d15194-6574-0c01-8f6f-15abd72b2cf6'), url=HttpUrl('https://example.com/')) """ # noqa: E501 link_id: Annotated[LinkId, PydanticField(description="リンクを識別できる UUID")] @@ -840,7 +840,7 @@ def link(self) -> HttpUrl: """ PostのX上でのURLを返す。 """ - return Url(f"https://x.com/{self.x_user.name}/status/{self.post_id}") + return HttpUrl(f"https://x.com/{self.x_user.name}/status/{self.post_id}") class PaginationMeta(BaseModel):