├── LICENSE ├── README.md ├── front ├── .eslintrc.json ├── .gitignore ├── README.md ├── next.config.js ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma │ └── schema.prisma ├── public │ ├── kokkai-today_qr.png │ ├── kokkai-today_qr_2.png │ └── parliament_alpha.png ├── src │ ├── app │ │ ├── [[...slugs]] │ │ │ └── page.tsx │ │ ├── api │ │ │ └── wordcounts │ │ │ │ └── route.ts │ │ ├── favicon.ico │ │ ├── fonts │ │ │ ├── GeistMonoVF.woff │ │ │ └── GeistVF.woff │ │ ├── globals.css │ │ └── layout.tsx │ ├── components │ │ ├── WordFlow.tsx │ │ ├── atom │ │ │ ├── ShareButton │ │ │ │ └── index.tsx │ │ │ └── WideFab │ │ │ │ └── index.tsx │ │ ├── mol │ │ │ └── FlatDatePicker │ │ │ │ └── index.tsx │ │ ├── org │ │ │ ├── GithubRepoCard │ │ │ │ └── index.tsx │ │ │ ├── SidePanel │ │ │ │ └── index.tsx │ │ │ ├── TipsCard │ │ │ │ └── index.tsx │ │ │ ├── TitleShareArea │ │ │ │ └── index.tsx │ │ │ └── WordDiggingModal │ │ │ │ └── index.tsx │ │ └── recoil │ │ │ └── RecoilProvider │ │ │ └── index.tsx │ ├── lib │ │ ├── prisma.ts │ │ └── slugs │ │ │ └── date-slug.ts │ ├── states │ │ ├── diggingWordState │ │ │ └── index.ts │ │ ├── isLoadingState │ │ │ └── index.ts │ │ └── selectedDateState │ │ │ └── index.ts │ └── theme │ │ └── theme.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json └── tasks ├── .env.example ├── .gitignore ├── Dockerfile ├── docker-compose.yml ├── main.py ├── package-lock.json ├── package.json ├── prisma ├── migrations │ ├── 20241101200124_init_with_schema │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── requirements.txt └── tasks.py /LICENSE: -------------------------------------------------------------------------------- 1 | よければいろんなジャンルや界隈で同じように可視化するのに自由にご利用ください 2 | 派生アプリ内のクレジットにオリジナルのリンクと @taniiicom とだけ記載いただけると嬉しいです! あとは MIT ライセンスでご自由にご利用ください 3 | 4 | Feel free to use this for visualizations in various genres or fields as you like. I’d appreciate it if you could include the original link and @taniiicom in the credits of derivative apps! Otherwise, please feel free to use it under the MIT License. 5 | 6 | --- 7 | 8 | MIT License 9 | 10 | Copyright (c) 2024 taniiicom 11 | 12 | Permission is hereby granted, free of charge, to any person obtaining a copy 13 | of this software and associated documentation files (the "Software"), to deal 14 | in the Software without restriction, including without limitation the rights 15 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | copies of the Software, and to permit persons to whom the Software is 17 | furnished to do so, subject to the following conditions: 18 | 19 | The above copyright notice and this permission notice shall be included in all 20 | copies or substantial portions of the Software. 21 | 22 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | SOFTWARE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## kokkai-today 2 | 3 | -> [https://kokkai-today.taniii.com/](https://kokkai-today.taniii.com/) 4 | 5 | https://github.com/user-attachments/assets/42bf6c71-c323-4249-bed0-ba6d10591cf5 6 | 7 | ## media coverage. / メディア掲載 8 | 9 | 以下のメディアで紹介していただきました. 10 | 11 | - Impress Watch 12 | https://internet.watch.impress.co.jp/docs/yajiuma/1637438.html 13 | - d メニュー 14 | https://topics.smt.docomo.ne.jp/article/internet_watch/trend/internet_watch-1637438?redirect=1 15 | - goo ニュース 16 | https://news.goo.ne.jp/article/internet_watch/trend/internet_watch-1637438.html 17 | 18 | ## community. / コミュニティ 19 | 20 | 公式 Discord にお気軽にご参加ください! 21 | Join our official Discord server! 22 | 23 | - https://discord.gg/TReYqKs5JW 24 | 25 | ## license. / ライセンス 26 | 27 | よければいろんなジャンルや界隈で同じように可視化するのに自由にご利用ください 28 | 派生アプリ内のクレジットにオリジナルのリンクと `@taniiicom` とだけ記載いただけると嬉しいです! 29 | あとは MIT ライセンスでご自由にご利用ください 30 | 31 | Feel free to use this for visualizations in various genres or fields as you like. 32 | I’d appreciate it if you could include the original link and @taniiicom in the credits of derivative apps! 33 | Otherwise, please feel free to use it under the MIT License. 34 | 35 | ## contact. / 問い合わせ 36 | 37 | - Taniii ([@taniiicom](https://github.com/taniiicom)) 38 | X: [@taniiicom](https://x.com/taniiicom) 39 | email: mail@taniii.com 40 | 41 | ## howto. / つかいかた 42 | 43 | - `tasks` : これは, 国会議事録から発言を収集し, 形態素解析 -> 抽出したキーワードを DB に保存するためのものです 44 | - `front` : フロントエンド 45 | -------------------------------------------------------------------------------- /front/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /front/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. 37 | -------------------------------------------------------------------------------- /front/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | typescript: { 3 | // !! 警告 !! 4 | // あなたのプロジェクトに型エラーがあったとしても、プロダクションビルドを正常に完了するために危険な許可をする。 5 | // !! 警告 !! 6 | ignoreBuildErrors: true, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /front/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kokkai-today", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/icons": "^2.2.4", 13 | "@chakra-ui/react": "^2.10.4", 14 | "@emotion/react": "^11.13.3", 15 | "@emotion/styled": "^11.13.0", 16 | "@prisma/client": "^5.21.1", 17 | "axios": "^1.7.7", 18 | "date-fns": "^4.1.0", 19 | "dayjs": "^1.11.13", 20 | "framer-motion": "^11.11.11", 21 | "next": "14.2.16", 22 | "next-themes": "^0.4.3", 23 | "prisma": "^5.21.1", 24 | "react": "^18", 25 | "react-datepicker": "^7.5.0", 26 | "react-dom": "^18", 27 | "react-icons": "^5.3.0", 28 | "recoil": "^0.7.7" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^20", 32 | "@types/react": "^18", 33 | "@types/react-dom": "^18", 34 | "eslint": "^8", 35 | "eslint-config-next": "14.2.16", 36 | "postcss": "^8", 37 | "tailwindcss": "^3.4.1", 38 | "typescript": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /front/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /front/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-js" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model WordCount { 17 | id Int @id @default(autoincrement()) 18 | date DateTime 19 | word String 20 | count Int 21 | 22 | @@unique([date, word]) 23 | @@map("word_counts") 24 | } 25 | -------------------------------------------------------------------------------- /front/public/kokkai-today_qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/public/kokkai-today_qr.png -------------------------------------------------------------------------------- /front/public/kokkai-today_qr_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/public/kokkai-today_qr_2.png -------------------------------------------------------------------------------- /front/public/parliament_alpha.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/public/parliament_alpha.png -------------------------------------------------------------------------------- /front/src/app/[[...slugs]]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/page.tsx 2 | "use client"; 3 | 4 | import { useState, useEffect } from "react"; 5 | import { useRecoilState, useSetRecoilState } from "recoil"; 6 | import { selectedDateState } from "@/states/selectedDateState"; 7 | import { isLoadingState } from "@/states/isLoadingState"; 8 | import { Box, Text } from "@chakra-ui/react"; 9 | import axios from "axios"; 10 | import WordFlow from "@/components/WordFlow"; 11 | import { isValidDateSlug } from "@/lib/slugs/date-slug"; 12 | import { useRouter } from "next/navigation"; 13 | import SidePanel from "@/components/org/SidePanel"; 14 | import TitleShareArea from "@/components/org/TitleShareArea"; 15 | import WordDiggingModal from "@/components/org/WordDiggingModal"; 16 | 17 | interface WordCount { 18 | id: number; 19 | date: string; 20 | word: string; 21 | count: number; 22 | } 23 | 24 | const HomePage: React.FC<{ 25 | params: { slugs: string[] }; 26 | }> = ({ params: { slugs } }) => { 27 | const [selectedDate, setSelectedDate] = useRecoilState(selectedDateState); 28 | const [wordCounts, setWordCounts] = useState([]); 29 | const setLoading = useSetRecoilState(isLoadingState); 30 | const [error, setError] = useState(""); 31 | 32 | const router = useRouter(); 33 | 34 | useEffect(() => { 35 | if (slugs && slugs.length > 0 && isValidDateSlug(slugs[0])) { 36 | setSelectedDate(slugs[0]); 37 | } else { 38 | router.push("/" + new Date().toISOString().slice(0, 10)); 39 | } 40 | }, [slugs, router]); 41 | 42 | useEffect(() => { 43 | if (selectedDate) { 44 | setLoading(true); 45 | setError(""); 46 | axios 47 | .get("/api/wordcounts", { params: { date: selectedDate } }) 48 | .then((response) => { 49 | setWordCounts(response.data); 50 | setLoading(false); 51 | }) 52 | .catch((error) => { 53 | console.error("データの取得に失敗しました", error); 54 | setError("データの取得に失敗しました"); 55 | setLoading(false); 56 | }); 57 | } else { 58 | setWordCounts([]); 59 | } 60 | 61 | // router.push(`/${leafPath.host}/${leafPath.owner}/${leafPath.repo}/${path}`); 62 | window.history.replaceState(null, "", `/${selectedDate}`); 63 | }, [selectedDate]); 64 | 65 | return ( 66 | 67 | 68 | 69 | 70 | 71 | {error ? ( 72 | 79 | 80 | {error} 81 | 82 | 83 | ) : wordCounts.length > 0 ? ( 84 | 85 | ) : selectedDate ? ( 86 | 93 | 94 | データがありません.
95 | 他の日を選択してください. 96 |
97 |
98 | ) : null} 99 |
100 | ); 101 | }; 102 | 103 | export default HomePage; 104 | -------------------------------------------------------------------------------- /front/src/app/api/wordcounts/route.ts: -------------------------------------------------------------------------------- 1 | // app/api/wordcounts/route.ts 2 | import { NextResponse } from "next/server"; 3 | import { prisma } from "@/lib/prisma"; 4 | 5 | export async function GET(request: Request) { 6 | const { searchParams } = new URL(request.url); 7 | const date = searchParams.get("date"); 8 | 9 | if (!date) { 10 | return NextResponse.json({ error: "日付が無効です" }, { status: 400 }); 11 | } 12 | 13 | const selectedDate = new Date(date); 14 | 15 | try { 16 | const wordCounts = await prisma.wordCount.findMany({ 17 | where: { 18 | date: selectedDate, 19 | }, 20 | orderBy: { 21 | count: "desc", 22 | }, 23 | }); 24 | 25 | return NextResponse.json(wordCounts); 26 | } catch (error) { 27 | console.error(error); 28 | return NextResponse.json( 29 | { error: "データの取得に失敗しました" }, 30 | { status: 500 } 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /front/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/src/app/favicon.ico -------------------------------------------------------------------------------- /front/src/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/src/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /front/src/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taniiicom/kokkai-today/b36954f14181b7927f54835698d7339d12540ff5/front/src/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /front/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /front/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import { ChakraProvider } from "@chakra-ui/react"; 5 | import theme from "@/theme/theme"; 6 | import Script from "next/script"; 7 | import { RecoilProvider } from "@/components/recoil/RecoilProvider"; 8 | 9 | const geistSans = localFont({ 10 | src: "./fonts/GeistVF.woff", 11 | variable: "--font-geist-sans", 12 | weight: "100 900", 13 | }); 14 | const geistMono = localFont({ 15 | src: "./fonts/GeistMonoVF.woff", 16 | variable: "--font-geist-mono", 17 | weight: "100 900", 18 | }); 19 | 20 | export const metadata: Metadata = { 21 | title: "#国会Today - kokkai-today / 今日の国会", 22 | description: 23 | "今日1日, 国会で話されたテーマを, 国会議事録の全発言から抽出しビジュアル化しています", 24 | }; 25 | 26 | export default function RootLayout({ 27 | children, 28 | }: Readonly<{ 29 | children: React.ReactNode; 30 | }>) { 31 | const gaId = process.env.GA_ID || ""; 32 | 33 | return ( 34 | 35 | 36 | 49 | 50 | 53 | 54 | {children} 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /front/src/components/WordFlow.tsx: -------------------------------------------------------------------------------- 1 | // components/WordFlow.tsx 2 | "use client"; 3 | 4 | import { diggingWordState } from "@/states/diggingWordState"; 5 | import { Box, Text, Image } from "@chakra-ui/react"; 6 | import { motion } from "framer-motion"; 7 | import { useSetRecoilState } from "recoil"; 8 | 9 | interface WordCount { 10 | id: number; 11 | word: string; 12 | count: number; 13 | } 14 | 15 | interface WordFlowProps { 16 | wordCounts: WordCount[]; 17 | } 18 | 19 | const MotionBox = motion(Box); 20 | 21 | export default function WordFlow({ wordCounts }: WordFlowProps) { 22 | const setDiggingWord = useSetRecoilState(diggingWordState); 23 | 24 | return ( 25 | 32 | {/* 下部に固定する背景画像 */} 33 | National Diet Building 44 | 45 | {/* 単語のアニメーション */} 46 | {wordCounts.map((wordCount, index) => { 47 | const fontSize = Math.min(50, wordCount.count * 2); // フォントサイズを調整 48 | const randomLeftPosition = Math.random() * 100; // 左右ランダム位置(0〜100%) 49 | const animationDuration = 15 + Math.random() * 5; // アニメーションの持続時間 50 | const animationDelay = index * 1; // アニメーションの開始をずらす 51 | const randomOpacity = 0.3 + Math.random() * 0.7; // ランダムな透明度 (0.3〜1.0) 52 | 53 | return ( 54 | { 69 | setDiggingWord(wordCount.word); 70 | }} 71 | > 72 | 79 | {wordCount.word} 80 | 81 | 82 | ); 83 | })} 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /front/src/components/atom/ShareButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // ShareData 型定義 4 | type ShareData = { 5 | title: string; 6 | text: string; 7 | url: string; 8 | }; 9 | 10 | // Props 型定義 11 | interface ShareButtonProps { 12 | shareData: ShareData; 13 | } 14 | 15 | const ShareButton: React.FC = ({ shareData }) => { 16 | const handleShare = async () => { 17 | try { 18 | // Web Share API がサポートされているか確認 19 | if (navigator.share) { 20 | await navigator.share(shareData); 21 | console.log("共有に成功しました"); 22 | } else { 23 | alert("このブラウザは Web Share API をサポートしていません"); 24 | } 25 | } catch (error) { 26 | console.error("共有に失敗しました", error); 27 | } 28 | }; 29 | 30 | return ; 31 | }; 32 | 33 | export default ShareButton; 34 | -------------------------------------------------------------------------------- /front/src/components/atom/WideFab/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Icon } from "@chakra-ui/react"; 3 | import { AddIcon } from "@chakra-ui/icons"; 4 | 5 | interface WideFabProps { 6 | text: string; 7 | icon?: React.ElementType; 8 | onClick: () => void; 9 | } 10 | 11 | const WideFab: React.FC = ({ text, icon = AddIcon, onClick }) => { 12 | return ( 13 | 27 | ); 28 | }; 29 | 30 | export default WideFab; 31 | -------------------------------------------------------------------------------- /front/src/components/mol/FlatDatePicker/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import DatePicker from "react-datepicker"; 4 | import "react-datepicker/dist/react-datepicker.css"; 5 | import { selectedDateState } from "@/states/selectedDateState"; 6 | import { Global, css } from "@emotion/react"; 7 | import { Box, Input } from "@chakra-ui/react"; 8 | 9 | // 日付の文字列を Date オブジェクトに変換する関数 10 | const parseDate = (dateString: string): Date => { 11 | const [year, month, day] = dateString.split("-").map(Number); 12 | return new Date(year, month - 1, day); 13 | }; 14 | 15 | // Date オブジェクトを "yyyy-mm-dd" 形式の文字列に変換する関数 16 | const formatDate = (date: Date): string => { 17 | const year = date.getFullYear(); 18 | const month = String(date.getMonth() + 1).padStart(2, "0"); 19 | const day = String(date.getDate()).padStart(2, "0"); 20 | return `${year}-${month}-${day}`; 21 | }; 22 | 23 | // CSSをTypeScriptのオブジェクトで定義 24 | const datePickerStyles = css({ 25 | ".react-datepicker": { 26 | fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', 27 | border: "1px solid #ccc", 28 | borderRadius: "4px", 29 | backgroundColor: "#fff", 30 | color: "#333", 31 | boxShadow: "0 2px 4px rgba(0,0,0,0.1)", 32 | }, 33 | ".react-datepicker__header": { 34 | backgroundColor: "#fff", 35 | borderBottom: "1px solid #ccc", 36 | paddingTop: "8px", 37 | }, 38 | ".react-datepicker__current-month": { 39 | fontSize: "1rem", 40 | fontWeight: 500, 41 | color: "#333", 42 | }, 43 | ".react-datepicker__day-names": { 44 | marginTop: "8px", 45 | }, 46 | ".react-datepicker__day-name": { 47 | fontSize: "0.75rem", 48 | color: "#666", 49 | }, 50 | ".react-datepicker__day": { 51 | width: "2rem", 52 | lineHeight: "2rem", 53 | margin: "0.1rem", 54 | fontSize: "0.875rem", 55 | color: "#333", 56 | "&:hover": { 57 | backgroundColor: "#f5f5f5", 58 | }, 59 | }, 60 | ".react-datepicker__day--selected": { 61 | backgroundColor: "#1976d2", 62 | color: "#fff", 63 | }, 64 | ".react-datepicker__day--today": { 65 | fontWeight: 700, 66 | }, 67 | }); 68 | 69 | const MyDatePicker: React.FC = () => { 70 | const [selectedDate, setSelectedDate] = useRecoilState(selectedDateState); 71 | 72 | return ( 73 |
74 | 75 | setSelectedDate(e.target.value)} 79 | placeholder="日付を選択してください" 80 | py={2} 81 | /> 82 | 83 | 84 | 85 | { 88 | if (date) { 89 | setSelectedDate(formatDate(date)); 90 | } 91 | }} 92 | inline 93 | /> 94 | 95 |
96 | ); 97 | }; 98 | 99 | export default MyDatePicker; 100 | -------------------------------------------------------------------------------- /front/src/components/org/GithubRepoCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text, Button, Icon, VStack, Link, Flex } from "@chakra-ui/react"; 3 | import { FaStar, FaGithub } from "react-icons/fa"; 4 | 5 | // GitHub API の型定義 6 | type GitHubRepo = { 7 | full_name: string; 8 | description: string; 9 | stargazers_count: number; 10 | }; 11 | 12 | interface GitHubRepoCardProps { 13 | repoUrl: string; 14 | repoData: GitHubRepo; 15 | } 16 | 17 | const GitHubRepoCard: React.FC = ({ 18 | repoUrl, 19 | repoData, 20 | }) => { 21 | return ( 22 | 30 | 31 | 32 | 33 | 34 | {repoData.full_name} 35 | 36 | 37 | {repoData.description || ""} 38 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default GitHubRepoCard; 52 | -------------------------------------------------------------------------------- /front/src/components/org/SidePanel/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Drawer, 4 | DrawerBody, 5 | DrawerContent, 6 | DrawerFooter, 7 | DrawerHeader, 8 | DrawerOverlay, 9 | IconButton, 10 | Link, 11 | Spinner, 12 | Text, 13 | useDisclosure, 14 | } from "@chakra-ui/react"; 15 | import { CgMenuMotion } from "react-icons/cg"; 16 | import { BsArrowLeft } from "react-icons/bs"; 17 | import React from "react"; 18 | import FlatDatePicker from "@/components/mol/FlatDatePicker"; 19 | import GithubRepoCard from "@/components/org/GithubRepoCard"; 20 | import TipsCard from "@/components/org/TipsCard"; 21 | import { selectedDateState } from "@/states/selectedDateState"; 22 | import { useRecoilState } from "recoil"; 23 | import { isLoadingState } from "@/states/isLoadingState"; 24 | 25 | const SidePanel = () => { 26 | const [isLoading] = useRecoilState(isLoadingState); 27 | const [selectedDate] = useRecoilState(selectedDateState); 28 | const { isOpen, onOpen, onClose } = useDisclosure(); 29 | 30 | // ホバー時にサイドパネルを開く 31 | const openSidePanel = () => { 32 | onOpen(); 33 | }; 34 | 35 | // ホバーが外れたらサイドパネルを閉じる 36 | const closeSidePanel = () => { 37 | onClose(); 38 | }; 39 | 40 | return ( 41 | <> 42 | {/* [uiux][design][animation] スマホでは FAB のクリックで対応 ^^ */} 43 | Fab > isLoading 時に Spinner 表示するように ^^ 47 | isOpen ? : isLoading ? : 48 | } 49 | position="fixed" 50 | top="20px" 51 | left="20px" 52 | color="white" 53 | bgColor={isOpen ? "#999" : "#00cdff"} 54 | _hover={{ bgColor: isOpen ? "#999" : "#00cdff" }} 55 | borderRadius="50%" 56 | onClick={isOpen ? closeSidePanel : openSidePanel} 57 | zIndex="popover" 58 | fontSize="25px" 59 | height="55px" 60 | width="55px" 61 | boxShadow="0px 6px 10px rgba(0, 0, 0, 0.15), 0px 1px 18px rgba(0, 0, 0, 0.1), 0px 3px 5px rgba(0, 0, 0, 0.2)" 62 | transition="all 0.4s ease-in-out" 63 | _active={{ 64 | boxShadow: "0px 4px 8px rgba(0, 0, 0, 0.2)", 65 | transform: "scale(0.95)", 66 | }} 67 | /> 68 | 69 | {/* // [uiux][design] 日付が日めくりカレンダーのように強調されるように ^^ */} 70 | 89 | 90 | 91 | {new Date(selectedDate).getDate()} 92 | 93 | 94 | 95 | {new Date(selectedDate).getFullYear()} -{" "} 96 | {new Date(selectedDate).getMonth() + 1} 97 | 98 | 99 | 100 | 101 | {/* [uiux][design] 左端 hover ですみやかに表示されるサイドパネル ^^ */} 102 | 110 | 111 | {/* [uiux][design] 大胆なサイドパネルデザイン ^^ */} 112 | 113 | 114 | 120 | 126 | #国会Today 127 | 128 | 129 | 130 | 131 | 132 | 133 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | Taniii @taniiicom 151 | 152 | 153 | source: 国立国会図書館 国会会議録 154 | 155 | 156 | 157 | 158 | 159 | 160 | ); 161 | }; 162 | 163 | export default SidePanel; 164 | -------------------------------------------------------------------------------- /front/src/components/org/TipsCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Text, Button, Icon, VStack, Link, Flex } from "@chakra-ui/react"; 3 | import { FaHeart } from "react-icons/fa"; 4 | import { CiCoffeeCup } from "react-icons/ci"; 5 | 6 | interface TipsCardProps { 7 | url: string; 8 | } 9 | 10 | const TipsCard: React.FC = ({ url }) => { 11 | return ( 12 | 20 | 21 | 22 | 23 | 24 | 支援する 25 | 26 | 27 | 28 | いいなと思っていただけたらコーヒー1杯分からご支援いただけます 29 | 30 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default TipsCard; 44 | -------------------------------------------------------------------------------- /front/src/components/org/TitleShareArea/index.tsx: -------------------------------------------------------------------------------- 1 | import { selectedDateState } from "@/states/selectedDateState"; 2 | import { Box, Text, Image, Button, useMediaQuery } from "@chakra-ui/react"; 3 | import React from "react"; 4 | import { useRecoilState } from "recoil"; 5 | import { IoShareSocialSharp, IoLogoTwitter } from "react-icons/io5"; 6 | 7 | // ShareData 型定義 8 | type ShareData = { 9 | title: string; 10 | text: string; 11 | url: string; 12 | }; 13 | 14 | // [uiux][design] タイトル, ハッシュタグ, QR コードが含まれる share エリア 15 | // [uiux][design][idea] クリックで Share API ^^ 16 | const TitleShareArea: React.FC = () => { 17 | const [selectedDate] = useRecoilState(selectedDateState); 18 | // [tips] PC/SP 出しわけ. chakra-ui の機能 ^^ 19 | const [isPC] = useMediaQuery("(min-width: 800px)"); 20 | 21 | const shareData: ShareData = { 22 | title: `#国会Today : ${selectedDate}`, 23 | text: `${selectedDate} の国会の全発言から抽出したキーワード`, 24 | url: `https://kokkai-today.taniii.com/${selectedDate}`, 25 | }; 26 | 27 | const handleShare = async () => { 28 | try { 29 | // Web Share API がサポートされているか確認 30 | if (navigator.share) { 31 | await navigator.share(shareData); 32 | console.log("共有に成功しました"); 33 | } else { 34 | alert("このブラウザは Web Share API をサポートしていません"); 35 | } 36 | } catch (error) { 37 | console.error("共有に失敗しました", error); 38 | } 39 | }; 40 | 41 | const twitterShareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent( 42 | shareData.text + " #国会Today\n" 43 | )}&url=${encodeURIComponent(shareData.url)}`; 44 | 45 | return ( 46 | { 65 | handleShare(); 66 | }} 67 | > 68 | 75 | 76 | 82 | #国会Today 83 | 84 | 85 | 93 | 98 | 99 | 100 | National Diet Building 108 | 109 | ); 110 | }; 111 | 112 | export default TitleShareArea; 113 | -------------------------------------------------------------------------------- /front/src/components/org/WordDiggingModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useRecoilState } from "recoil"; 3 | import { diggingWordState } from "@/states/diggingWordState"; // Recoil の状態 4 | import { 5 | Box, 6 | Text, 7 | Modal, 8 | ModalOverlay, 9 | ModalContent, 10 | ModalHeader, 11 | ModalBody, 12 | ModalCloseButton, 13 | Spinner, 14 | VStack, 15 | Link, 16 | HStack, 17 | } from "@chakra-ui/react"; 18 | import { motion } from "framer-motion"; 19 | import axios from "axios"; 20 | 21 | // 型定義 22 | interface Character { 23 | id: string; 24 | name: string; 25 | profile: string; 26 | } 27 | 28 | interface RecordItem { 29 | text: string; // 発言の内容 30 | } 31 | 32 | interface RecordMeta { 33 | casts: string[]; // 発言者リスト 34 | castGroup?: string; // 発言者のグループ(例: 政党名) 35 | } 36 | 37 | interface RecordData { 38 | id: string; // レコードのID 39 | meta: RecordMeta; // レコードのメタデータ 40 | items: RecordItem[]; // 発言のリスト 41 | issueID: string; // 国会会議のID 42 | publishedAt: string; // 公開日時を追加 43 | } 44 | 45 | interface BookCard { 46 | id: string; // 書籍カードのID 47 | publishedAt: string; // 公開日時 48 | } 49 | 50 | interface Result { 51 | bookCard: BookCard; // 書籍カード情報 52 | records: Omit[]; // 会議の発言記録 53 | characters: Record; // 発言者情報 54 | } 55 | 56 | interface ApiResponse { 57 | results: Result[]; // APIの検索結果 58 | } 59 | 60 | const WordDiggingModal: React.FC = () => { 61 | const [diggingWord, setDiggingWord] = useRecoilState(diggingWordState); // 対象の状態を取得 62 | const [loading, setLoading] = useState(false); // ローディング状態 63 | const [records, setRecords] = useState([]); // 取得したレコードを格納 64 | const [characters, setCharacters] = useState>({}); // 発言者情報 65 | 66 | // [animation] ふわっと表示 ^^ 67 | const MotionBox = motion(Box); // framer-motion を使用したアニメーション付きボックス 68 | 69 | // [arc] diggingWordState の中身が `""` のとき, DiggingWordModal 非表示, 何らかの文字列が含まれた時に表示 ^^ 70 | // diggingWord が "" のときはモーダルを閉じる 71 | const isVisible = diggingWord !== ""; // モーダルの表示状態 72 | 73 | // モーダルを閉じる処理 74 | const handleClose = () => { 75 | setDiggingWord(""); // 状態をリセットして非表示にする 76 | setRecords([]); // レコードをクリア 77 | setCharacters({}); // 発言者情報をクリア 78 | }; 79 | 80 | // 日付を `yyyy/mm/dd` 形式にフォーマット 81 | const formatDate = (dateString: string): string => { 82 | const date = new Date(dateString); 83 | const year = date.getFullYear(); 84 | const month = String(date.getMonth() + 1).padStart(2, "0"); // 月は 0 ベース 85 | const day = String(date.getDate()).padStart(2, "0"); 86 | return `${year}/${month}/${day}`; 87 | }; 88 | 89 | // API からデータを取得する関数 90 | // [idea] `bunko.jp` さんとコラボ ^^ 91 | const fetchRecords = async (keyword: string): Promise => { 92 | setLoading(true); 93 | try { 94 | const response = await axios.get( 95 | `https://api.bunko.jp/api/search/keyword?query=${encodeURIComponent( 96 | keyword 97 | )}&startsWith=jp.go.ndl.kokkai` 98 | ); 99 | 100 | const data = response.data; 101 | if (data.results) { 102 | const extractedRecords: RecordData[] = data.results.flatMap( 103 | (result) => { 104 | const issueID = result.bookCard.id.split(".").pop() || "unknown"; // issueID を抽出 105 | const publishedAt = result.bookCard.publishedAt; // 公開日時を取得 106 | return result.records.map((record) => ({ 107 | ...record, 108 | issueID, 109 | publishedAt, // 各 record に publishedAt を追加 110 | })); 111 | } 112 | ); 113 | setRecords(extractedRecords); 114 | setCharacters(data.results[0]?.characters || {}); 115 | } 116 | } catch (error) { 117 | console.error("データの取得に失敗しました", error); 118 | } finally { 119 | setLoading(false); 120 | } 121 | }; 122 | 123 | // diggingWord が変更されたときにデータを取得 124 | useEffect(() => { 125 | if (diggingWord) { 126 | fetchRecords(diggingWord); 127 | } 128 | }, [diggingWord]); 129 | 130 | return ( 131 | <> 132 | {/* モーダルの表示ロジック */} 133 | {isVisible && ( 134 | 135 | {/* [design][uiux] モーダルの外側タップで非表示 ^^ */} 136 | 137 | 146 | 152 | Word Digging 153 | 154 | 155 | 156 | 163 | {diggingWord} 164 | 165 | 166 | {/* [uiux][design][animation] loading, 下からふわっと表示, リスト表示デザイン ^^ */} 167 | {loading ? ( 168 | 169 | 170 | データを探索しています... 171 | 172 | ) : ( 173 | 174 | {records.length > 0 ? ( 175 | records.map((record) => { 176 | const speechID = record.id; // speechID を取得 177 | const link = `https://kokkai.bunko.jp/books/jp.go.ndl.kokkai.${record.issueID}#${speechID}`; 178 | const speakerId = record.meta.casts[0]; // 発言者ID 179 | const speakerName = 180 | characters[speakerId]?.name || "不明"; // 発言者名 181 | const formattedDate = formatDate(record.publishedAt); // 日付をフォーマット 182 | 183 | return ( 184 | 191 | 192 | 193 | {speakerName} ({record.meta.castGroup || "不明"} 194 | ) 195 | 196 | 197 | {formattedDate} 198 | 199 | 200 | 201 | {record.items[0]?.text || "発言内容なし"} 202 | 203 | 204 | 詳細を見る 205 | 206 | 207 | ); 208 | }) 209 | ) : ( 210 | 該当する発言が見つかりませんでした。 211 | )} 212 | 213 | )} 214 | 215 | 216 | 217 | 218 | 219 | )} 220 | 221 | ); 222 | }; 223 | 224 | export default WordDiggingModal; 225 | -------------------------------------------------------------------------------- /front/src/components/recoil/RecoilProvider/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | // [tips] app-router 対応のため追加. recoil は client でしか使えないので, `use client` でラップする. ^^ 3 | 4 | import { ReactNode } from "react"; 5 | import { RecoilRoot } from "recoil"; 6 | 7 | interface RecoilProviderProps { 8 | children: ReactNode; 9 | } 10 | 11 | export const RecoilProvider: React.FC = ({ children }) => { 12 | return {children}; 13 | }; 14 | -------------------------------------------------------------------------------- /front/src/lib/prisma.ts: -------------------------------------------------------------------------------- 1 | // lib/prisma.ts 2 | import { PrismaClient } from "@prisma/client"; 3 | 4 | const globalForPrisma = global as unknown as { prisma: PrismaClient }; 5 | 6 | export const prisma = 7 | globalForPrisma.prisma || 8 | new PrismaClient({ 9 | log: ["query"], 10 | }); 11 | 12 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 13 | -------------------------------------------------------------------------------- /front/src/lib/slugs/date-slug.ts: -------------------------------------------------------------------------------- 1 | export const isValidDateSlug = (slug: string) => { 2 | const regex = /^\d{4}-\d{2}-\d{2}$/; 3 | return regex.test(slug); 4 | }; 5 | -------------------------------------------------------------------------------- /front/src/states/diggingWordState/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { atom } from "recoil"; 4 | 5 | export const diggingWordState = atom({ 6 | key: "diggingWordState", // ユニークなキーを設定 7 | default: "", // 初期値 8 | }); 9 | -------------------------------------------------------------------------------- /front/src/states/isLoadingState/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { atom } from "recoil"; 4 | 5 | export const isLoadingState = atom({ 6 | key: "isLoadingState", // ユニークなキーを設定 7 | default: false, // 初期値 8 | }); 9 | -------------------------------------------------------------------------------- /front/src/states/selectedDateState/index.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { atom } from "recoil"; 4 | 5 | export const selectedDateState = atom({ 6 | key: "selectedDateState", // ユニークなキーを設定 7 | default: "2024-11-11", // 初期値 8 | }); 9 | -------------------------------------------------------------------------------- /front/src/theme/theme.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { extendTheme, ThemeConfig } from "@chakra-ui/react"; 3 | 4 | const config: ThemeConfig = { 5 | initialColorMode: "light", // デフォルトは "light" 6 | useSystemColorMode: false, // システムのカラーモードに従うかどうか 7 | }; 8 | 9 | const theme = extendTheme({ config }); 10 | 11 | export default theme; 12 | -------------------------------------------------------------------------------- /front/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | background: "var(--background)", 13 | foreground: "var(--foreground)", 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | }; 19 | export default config; 20 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmitOnError": false, 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /front/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "env": { 4 | "TS_NODE_IGNORE_DIAGNOSTICS": "1" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tasks/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | GA_ID= 3 | -------------------------------------------------------------------------------- /tasks/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | venv 4 | -------------------------------------------------------------------------------- /tasks/Dockerfile: -------------------------------------------------------------------------------- 1 | # ベースイメージとしてPython 3.9を使用 2 | FROM python:3.9-slim 3 | 4 | # 作業ディレクトリの作成と移動 5 | WORKDIR /app 6 | 7 | # 必要なパッケージをインストール 8 | RUN apt-get update && \ 9 | apt-get install -y --no-install-recommends \ 10 | build-essential \ 11 | curl \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # Node.jsとnpmをインストール (Prisma CLIに必要) 15 | RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \ 16 | apt-get install -y nodejs 17 | 18 | # pipをアップグレード 19 | RUN pip install --upgrade pip 20 | 21 | # Pythonの依存パッケージファイルをコピー 22 | COPY requirements.txt . 23 | 24 | # Pythonパッケージのインストール 25 | RUN pip install --no-cache-dir -r requirements.txt 26 | 27 | # アプリケーションコードをコンテナにコピー 28 | COPY . . 29 | 30 | # スクリプト実行用のエントリーポイント 31 | CMD ["python", "main.py"] 32 | -------------------------------------------------------------------------------- /tasks/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | app: 5 | build: . 6 | environment: 7 | - DATABASE_URL=${DATABASE_URL} 8 | networks: 9 | - app-network 10 | volumes: 11 | - .:/app 12 | 13 | networks: 14 | app-network: 15 | driver: bridge 16 | -------------------------------------------------------------------------------- /tasks/main.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | print("コンテナが起動しました。Prisma操作を行うには、コンテナに入ってください。") 4 | # [tips] docker コンテナ内で操作を継続するために無限ループを実行 ^^ 5 | while True: 6 | time.sleep(3600) # 1時間ごとに待機を継続 7 | -------------------------------------------------------------------------------- /tasks/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "lockfileVersion": 2, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@prisma/client": "^5.17.0" 9 | }, 10 | "devDependencies": { 11 | "prisma": "^5.17.0" 12 | } 13 | }, 14 | "node_modules/@prisma/client": { 15 | "version": "5.17.0", 16 | "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.17.0.tgz", 17 | "integrity": "sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==", 18 | "hasInstallScript": true, 19 | "engines": { 20 | "node": ">=16.13" 21 | }, 22 | "peerDependencies": { 23 | "prisma": "*" 24 | }, 25 | "peerDependenciesMeta": { 26 | "prisma": { 27 | "optional": true 28 | } 29 | } 30 | }, 31 | "node_modules/@prisma/debug": { 32 | "version": "5.17.0", 33 | "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.17.0.tgz", 34 | "integrity": "sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==", 35 | "devOptional": true 36 | }, 37 | "node_modules/@prisma/engines": { 38 | "version": "5.17.0", 39 | "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.17.0.tgz", 40 | "integrity": "sha512-+r+Nf+JP210Jur+/X8SIPLtz+uW9YA4QO5IXA+KcSOBe/shT47bCcRMTYCbOESw3FFYFTwe7vU6KTWHKPiwvtg==", 41 | "devOptional": true, 42 | "hasInstallScript": true, 43 | "dependencies": { 44 | "@prisma/debug": "5.17.0", 45 | "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 46 | "@prisma/fetch-engine": "5.17.0", 47 | "@prisma/get-platform": "5.17.0" 48 | } 49 | }, 50 | "node_modules/@prisma/engines-version": { 51 | "version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 52 | "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053.tgz", 53 | "integrity": "sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==", 54 | "devOptional": true 55 | }, 56 | "node_modules/@prisma/fetch-engine": { 57 | "version": "5.17.0", 58 | "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.17.0.tgz", 59 | "integrity": "sha512-ESxiOaHuC488ilLPnrv/tM2KrPhQB5TRris/IeIV4ZvUuKeaicCl4Xj/JCQeG9IlxqOgf1cCg5h5vAzlewN91Q==", 60 | "devOptional": true, 61 | "dependencies": { 62 | "@prisma/debug": "5.17.0", 63 | "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 64 | "@prisma/get-platform": "5.17.0" 65 | } 66 | }, 67 | "node_modules/@prisma/get-platform": { 68 | "version": "5.17.0", 69 | "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.17.0.tgz", 70 | "integrity": "sha512-UlDgbRozCP1rfJ5Tlkf3Cnftb6srGrEQ4Nm3og+1Se2gWmCZ0hmPIi+tQikGDUVLlvOWx3Gyi9LzgRP+HTXV9w==", 71 | "devOptional": true, 72 | "dependencies": { 73 | "@prisma/debug": "5.17.0" 74 | } 75 | }, 76 | "node_modules/prisma": { 77 | "version": "5.17.0", 78 | "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.17.0.tgz", 79 | "integrity": "sha512-m4UWkN5lBE6yevqeOxEvmepnL5cNPEjzMw2IqDB59AcEV6w7D8vGljDLd1gPFH+W6gUxw9x7/RmN5dCS/WTPxA==", 80 | "devOptional": true, 81 | "hasInstallScript": true, 82 | "dependencies": { 83 | "@prisma/engines": "5.17.0" 84 | }, 85 | "bin": { 86 | "prisma": "build/index.js" 87 | }, 88 | "engines": { 89 | "node": ">=16.13" 90 | } 91 | } 92 | }, 93 | "dependencies": { 94 | "@prisma/client": { 95 | "version": "5.17.0", 96 | "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.17.0.tgz", 97 | "integrity": "sha512-N2tnyKayT0Zf7mHjwEyE8iG7FwTmXDHFZ1GnNhQp0pJUObsuel4ZZ1XwfuAYkq5mRIiC/Kot0kt0tGCfLJ70Jw==", 98 | "requires": {} 99 | }, 100 | "@prisma/debug": { 101 | "version": "5.17.0", 102 | "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.17.0.tgz", 103 | "integrity": "sha512-l7+AteR3P8FXiYyo496zkuoiJ5r9jLQEdUuxIxNCN1ud8rdbH3GTxm+f+dCyaSv9l9WY+29L9czaVRXz9mULfg==", 104 | "devOptional": true 105 | }, 106 | "@prisma/engines": { 107 | "version": "5.17.0", 108 | "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.17.0.tgz", 109 | "integrity": "sha512-+r+Nf+JP210Jur+/X8SIPLtz+uW9YA4QO5IXA+KcSOBe/shT47bCcRMTYCbOESw3FFYFTwe7vU6KTWHKPiwvtg==", 110 | "devOptional": true, 111 | "requires": { 112 | "@prisma/debug": "5.17.0", 113 | "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 114 | "@prisma/fetch-engine": "5.17.0", 115 | "@prisma/get-platform": "5.17.0" 116 | } 117 | }, 118 | "@prisma/engines-version": { 119 | "version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 120 | "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053.tgz", 121 | "integrity": "sha512-tUuxZZysZDcrk5oaNOdrBnnkoTtmNQPkzINFDjz7eG6vcs9AVDmA/F6K5Plsb2aQc/l5M2EnFqn3htng9FA4hg==", 122 | "devOptional": true 123 | }, 124 | "@prisma/fetch-engine": { 125 | "version": "5.17.0", 126 | "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.17.0.tgz", 127 | "integrity": "sha512-ESxiOaHuC488ilLPnrv/tM2KrPhQB5TRris/IeIV4ZvUuKeaicCl4Xj/JCQeG9IlxqOgf1cCg5h5vAzlewN91Q==", 128 | "devOptional": true, 129 | "requires": { 130 | "@prisma/debug": "5.17.0", 131 | "@prisma/engines-version": "5.17.0-31.393aa359c9ad4a4bb28630fb5613f9c281cde053", 132 | "@prisma/get-platform": "5.17.0" 133 | } 134 | }, 135 | "@prisma/get-platform": { 136 | "version": "5.17.0", 137 | "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.17.0.tgz", 138 | "integrity": "sha512-UlDgbRozCP1rfJ5Tlkf3Cnftb6srGrEQ4Nm3og+1Se2gWmCZ0hmPIi+tQikGDUVLlvOWx3Gyi9LzgRP+HTXV9w==", 139 | "devOptional": true, 140 | "requires": { 141 | "@prisma/debug": "5.17.0" 142 | } 143 | }, 144 | "prisma": { 145 | "version": "5.17.0", 146 | "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.17.0.tgz", 147 | "integrity": "sha512-m4UWkN5lBE6yevqeOxEvmepnL5cNPEjzMw2IqDB59AcEV6w7D8vGljDLd1gPFH+W6gUxw9x7/RmN5dCS/WTPxA==", 148 | "devOptional": true, 149 | "requires": { 150 | "@prisma/engines": "5.17.0" 151 | } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /tasks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "prisma": "^5.17.0" 4 | }, 5 | "dependencies": { 6 | "@prisma/client": "^5.17.0" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tasks/prisma/migrations/20241101200124_init_with_schema/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "word_counts" ( 3 | "id" SERIAL NOT NULL, 4 | "date" TIMESTAMP(3) NOT NULL, 5 | "word" TEXT NOT NULL, 6 | "count" INTEGER NOT NULL, 7 | 8 | CONSTRAINT "word_counts_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "word_counts_date_word_key" ON "word_counts"("date", "word"); 13 | -------------------------------------------------------------------------------- /tasks/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /tasks/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? 5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init 6 | 7 | generator client { 8 | provider = "prisma-client-py" 9 | } 10 | 11 | datasource db { 12 | provider = "postgresql" 13 | url = env("DATABASE_URL") 14 | } 15 | 16 | model WordCount { 17 | id Int @id @default(autoincrement()) 18 | date DateTime 19 | word String 20 | count Int 21 | 22 | @@unique([date, word]) 23 | @@map("word_counts") 24 | } 25 | -------------------------------------------------------------------------------- /tasks/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | janome 3 | prisma 4 | python-dotenv 5 | psycopg2-binary 6 | -------------------------------------------------------------------------------- /tasks/tasks.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import requests 4 | from janome.tokenizer import Tokenizer 5 | from concurrent.futures import ThreadPoolExecutor 6 | from collections import Counter 7 | from datetime import datetime 8 | from dotenv import load_dotenv 9 | import psycopg2 10 | from psycopg2.extras import execute_values 11 | 12 | # 環境変数を読み込み 13 | load_dotenv() 14 | DATABASE_URL = os.getenv("DATABASE_URL") 15 | 16 | def fetch_speeches(date, start_record=1, maximum_records=100): 17 | base_url = "https://kokkai.ndl.go.jp/api/speech" 18 | speeches = [] 19 | while True: 20 | print(f"Fetching records from {start_record} to {start_record + maximum_records - 1} for date {date}...") 21 | 22 | params = { 23 | "from": date, 24 | "until": date, 25 | "startRecord": start_record, 26 | "maximumRecords": maximum_records, 27 | "recordPacking": "json" 28 | } 29 | response = requests.get(base_url, params=params) 30 | 31 | if response.status_code == 200: 32 | data = response.json() 33 | if "speechRecord" in data: 34 | speeches.extend(data['speechRecord']) 35 | start_record += maximum_records 36 | print(f"Retrieved {len(data['speechRecord'])} records. Total records so far: {len(speeches)}") 37 | 38 | # 全てのデータを取得したら終了 39 | if len(data['speechRecord']) < maximum_records: 40 | print("All records for the date have been fetched.") 41 | break 42 | else: 43 | print("No more speech records found.") 44 | break 45 | else: 46 | print(f"Failed to fetch data for {date}. Status code: {response.status_code}") 47 | break 48 | return speeches 49 | 50 | def remove_speaker_name_and_skip_lines(text): 51 | lines = text.splitlines() 52 | filtered_lines = [] 53 | for line in lines: 54 | # 全角スペースが2つ以上で始まる行を除外 55 | if re.match(r"^ {2,}", line): 56 | continue 57 | # 行頭から最初のスペースまでの部分(人名部分)を削除 58 | line = re.sub(r"^○.*?\s", "", line) 59 | filtered_lines.append(line) 60 | return "\n".join(filtered_lines) 61 | 62 | def parse_text(text): 63 | tokenizer = Tokenizer() 64 | words = [] 65 | temp_word = "" 66 | 67 | # テキストから人名部分と不要な行を除去 68 | text = remove_speaker_name_and_skip_lines(text) 69 | 70 | for token in tokenizer.tokenize(text): 71 | # 名詞であれば一時的に保存し、次の名詞に連結 72 | if token.part_of_speech.startswith("名詞"): 73 | temp_word += token.surface 74 | else: 75 | # 名詞の連続が終わった場合、保存してリセット 76 | if temp_word: 77 | words.append(temp_word) 78 | temp_word = "" 79 | 80 | # 最後の名詞の連続を処理 81 | if temp_word: 82 | words.append(temp_word) 83 | 84 | # 出現回数をカウントして返す 85 | return Counter(words) 86 | 87 | def save_to_postgres(date, word_counts): 88 | # データベースに接続 89 | conn = psycopg2.connect(DATABASE_URL) 90 | cursor = conn.cursor() 91 | 92 | # 各単語の出現回数を挿入または更新 93 | for word, count in word_counts.items(): 94 | cursor.execute(""" 95 | INSERT INTO word_counts (date, word, count) 96 | VALUES (%s, %s, %s) 97 | ON CONFLICT (date, word) 98 | DO UPDATE SET count = word_counts.count + EXCLUDED.count 99 | """, (date, word, count)) 100 | 101 | # コミットして接続を閉じる 102 | conn.commit() 103 | cursor.close() 104 | conn.close() 105 | print(f"Data for {date} has been saved to the database.") 106 | 107 | def process_speeches(date): 108 | speeches = fetch_speeches(date, start_record=1, maximum_records=100) 109 | all_word_counts = Counter() 110 | 111 | print(f"Starting text parsing and word count aggregation for {len(speeches)} speeches...") 112 | with ThreadPoolExecutor() as executor: 113 | results = executor.map(parse_text, [speech['speech'] for speech in speeches]) 114 | for idx, word_count in enumerate(results, start=1): 115 | all_word_counts.update(word_count) 116 | if idx % 10 == 0 or idx == len(speeches): 117 | print(f"Processed {idx}/{len(speeches)} speeches.") 118 | 119 | save_to_postgres(date, all_word_counts) 120 | 121 | def get_valid_date(): 122 | while True: 123 | date_input = input("日付を 'YYYY-MM-DD' 形式で入力してください: ") 124 | try: 125 | # 日付形式をチェック 126 | date = datetime.strptime(date_input, "%Y-%m-%d").date() 127 | return date_input # 入力が正しければ返す 128 | except ValueError: 129 | print("日付の形式が正しくありません。もう一度お試しください。") 130 | 131 | if __name__ == "__main__": 132 | target_date = get_valid_date() # 日付の入力を待機 133 | print(f"Processing speeches for date: {target_date}") 134 | process_speeches(target_date) 135 | --------------------------------------------------------------------------------