尚未收集仓库信息。
尚未检测到更新。GitHub 发布版本后会显示在这里。
台股選股
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗面向台灣投資人的基本面選股平台。可以用本益比、殖利率、ROE、營收成長、負債比等條件,篩選上市、上櫃股票。 只使用免費資料來源、部署在免費方案上,不需要付費 API。
本站資料僅供參考,不構成投資建議。
GitHub Actions cron ──► Python ETL(etl/)──► Supabase Postgres ──► Next.js(Vercel)
TWSE / TPEx OpenAPI 正規化、計算指標 screen_snapshot 瀏覽器端篩選
MOPS 彙總報表 upsert RLS 只開放讀取 個股頁 ISR
└─ 完成後呼叫 /api/revalidate 讓快取失效
| 層 | 技術 |
|---|---|
| 前端 | Next.js 16(App Router)、React 19、TypeScript(strict)、Tailwind CSS v4、shadcn/ui |
| 表格/圖表/URL 狀態 | TanStack Virtual(虛擬捲動)、Recharts(shadcn chart)、nuqs |
| 資料庫/認證 | Supabase(Postgres + RLS、Auth) |
| ETL | Python 3.12 |
| 測試 | Vitest + Testing Library、Playwright、pytest |
| 部署 | Vercel Hobby(非商業)、GitHub Actions |
src/
app/ App Router 頁面、Route Handler、sitemap / robots
components/
ui/ shadcn/ui 元件(由 CLI 產生)
layout/ 頁首、頁尾
screener/ 選股器 UI(篩選面板、策略列、虛擬捲動表格、匯出)
stock/ 個股頁 UI(重點指標、營收與 EPS 圖表、季報表、股利表)
lib/
env.ts 環境變數 schema(zod)
env.server.ts 伺服器專用環境變數(server-only)
format.ts 數值格式化(千分位、%、億元)
site.ts 網站設定、免責聲明、資料來源
screener/ 選股器的純函式:指標定義、篩選排序、策略模板、網址格式、CSV、資料過期判斷
data/snapshot.ts 讀取快照(use cache + cacheTag,server-only)
data/stock.ts 讀取個股資料(公司、快照、12 季財報、24 個月營收、股利)
stock/compute.ts 個股頁的純函式:單季比率、營收序列、年度股利合計
og-font.ts OG 圖片用的中文字型子集(Google Fonts)
supabase/ public(唯讀、可快取)、server(帶 session)、client(瀏覽器)、admin(service_role)
types/database.ts 由 `npm run db:types` 產生,不要手動修改
tests/e2e/ Playwright E2E 測試
tests/db/ Supabase RLS 整合測試
scripts/ 開發用腳本(型別產生)
etl/
scripts/verify_sources.py 資料端點驗證腳本
tests/ pytest
reports/ 驗證報告輸出
supabase/ migrations、seed(P1)
.github/workflows/ CI 與 ETL 排程
需要 Node.js ≥ 20.9(建議使用 .nvmrc 指定的 24)、Python 3.12。
npm install
cp .env.example .env.local # 依說明填入 Supabase 等設定(P0 不需要)
npm run dev # http://localhost:3000
.env.local。python etl/scripts/check_supabase.pynpx supabase login
npx supabase link --project-ref <你的 project ref>
npm run db:push # 套用 supabase/migrations/
npm run db:types # 產生 src/types/database.ts
ETL:
cd etl
python -m venv .venv
.venv\Scripts\activate # macOS / Linux:source .venv/bin/activate
pip install -e ".[dev]"
| 指令 | 用途 |
|---|---|
npm run dev | 啟動開發伺服器(Turbopack) |
npm run build / npm start | 正式版 build 與啟動 |
npm run lint | ESLint |
npm run typecheck | TypeScript 型別檢查 |
npm run format / format:check | Prettier 格式化與檢查 |
npm test | Vitest 單元測試 |
npm run e2e | Playwright E2E(會自動 build 並啟動;第一次執行前要跑 npx playwright install chromium) |
npm run check | lint、typecheck、format、unit 一次跑完 |
npm run test:db | 對 Supabase 專案跑 RLS/權限整合測試(需要 .env.local) |
npm run db:push | 把新的 migration 套用到已連結的專案 |
npm run db:status | 列出本機與遠端各自套用了哪些 migration(不需要 Docker) |
npm run db:types | 重新產生資料庫 TypeScript 型別(schema 有變更時執行) |
python etl/scripts/check_supabase.py | 檢查 .env.local 的 Supabase 設定與連線(不會印出 key) |
python etl/scripts/verify_sources.py | 實測所有資料端點,輸出 etl/reports/sources.md |
cd etl && pytest / ruff check . | ETL 測試與 lint |
| 資料 | 上市(TWSE) | 上櫃(TPEx) |
|---|---|---|
| 公司基本資料 | opendata/t187ap03_L | mopsfin_t187ap03_O |
| 日行情 | exchangeReport/STOCK_DAY_ALL | tpex_mainboard_daily_close_quotes |
| 本益比/殖利率/淨值比 | exchangeReport/BWIBBU_ALL | tpex_mainboard_peratio_analysis |
| 月營收 | opendata/t187ap05_L | mopsfin_t187ap05_O |
| 綜合損益表 | opendata/t187ap06_L_{ci,fh,basi,bd,ins,mim} | mopsfin_t187ap06_O_* |
| 資產負債表 | opendata/t187ap07_L_* | mopsfin_t187ap07_O_* |
| 營益分析 | opendata/t187ap17_L | mopsfin_187ap17_O |
| 股利分派 | opendata/t187ap45_L | mopsfin_t187ap39_O |
| 歷史財報回補 | MOPS ajax_t163sb04 等彙總報表 | 同左(TYPEK=otc) |
處理資料時要注意:
1151019 = 2026-10-19,11509 = 2026 年 9 月)。etl/)純 Python,只依賴 httpx,透過 Supabase REST API 寫入(使用 service_role key)。
etl/tw_etl/
sources/openapi.py TWSE / TPEx OpenAPI:公司、行情、估值、最新月營收、官方營益分析
sources/mops.py MOPS:季報彙總表(損益、資產負債、現金流量)、月營收歷史、股利分派
transform/financials.py 欄位對應(各產業格式)、年度累計 → 單季
transform/snapshot.py 選股指標計算、寫入前合理性檢查
load/supabase.py 分批 upsert、分頁讀取
jobs.py daily / monthly / quarterly / dividends / snapshot / backfill
runs.py etl_runs 執行紀錄、data_freshness
verify.py 與官方「營益分析」交叉比對
指令(在 etl/ 目錄) | 內容 | 排程(台灣時間) |
|---|---|---|
python -m tw_etl daily | 公司基本資料、行情(含開高低)、估值,接著重算快照 | 週一至五 14:03、隔天 07:47 補抓 |
python -m tw_etl monthly | 最新月營收 | 每月 1–15 日 21:23 |
python -m tw_etl quarterly | 去年與今年各季季報(MOPS) | 財報季(3、4、5、8、11 月)每天 22:33,其他月份每週日 22:33 |
python -m tw_etl dividends | 去年與今年董事會決議的股利 | 每週六 22:43 |
python -m tw_etl snapshot | 只重算快照 | — |
python -m tw_etl backfill | 一次性歷史回補(季報 2023 起、營收 36 個月、股利 10 年、收盤行情 45 天) | 手動 |
python -m tw_etl verify-margins | 用單季值重組累計毛利率、營益率,與官方營益分析比對 | 手動 |
所有指令都可以加 --dry-run,只抓取和計算,不寫入資料庫。
每日行情的來源與備援(sources/quotes.py):
| 順序 | 來源 | 說明 |
|---|---|---|
| 1 | 證交所/櫃買中心官網 JSON | 主要來源。與 OpenAPI 是同一份資料、定義相同,但通常較早更新 |
| 2 | 證交所/櫃買中心 OpenAPI | 備援 |
| 3 | FinMind(降級模式) | 前兩者都失敗時才使用。只補上一份快照中市值前 300 大的收盤價,因為免費版只能逐檔查詢 |
daily_valuation.source;每次選擇的結果和各來源的狀態記在 etl_runs.details。設計重點:
NULL,不會猜測。GitHub Actions(.github/workflows/etl.yml)需要設定以下項目:
NEXT_PUBLIC_SUPABASE_URL、SUPABASE_SERVICE_ROLE_KEY、REVALIDATE_SECRETNEXT_PUBLIC_SITE_URL也可以在 Actions 頁面手動執行(workflow_dispatch),選擇要跑的工作並加上參數。
- 排程刻意避開整點。GitHub 在整點負載高,實測延遲 2~4 小時,偶爾還會略過。daily 一天排兩次就是為了互相備援。
- 公開 repo 連續 60 天沒有任何 commit 時,GitHub 會自動停用排程,屆時要到 Actions 頁面重新啟用。
- 每次執行的結果(狀態、筆數、資料基準、重試次數)會寫入 Actions 的 Step Summary(需登入檢視),也會記錄在資料庫的
etl_runs。
/screener)getSnapshot() 用 'use cache'、cacheTag('snapshot')、cacheLife('hours') 讀取整份快照(約 2,000 列,分頁突破 Supabase 1,000 列的上限)。結果會放進頁面的靜態外殼,由 CDN 提供;每小時、或 ETL 通知時才重新讀取資料庫。/screener?dy=4~&roe=8~&pe=~15&m=twse&ind=半導體業&q=台積&s=dy.desc&cols=price,pe,dy
最小~最大,任一邊都可以省略。每個指標的參數名稱定義在 src/lib/screener/metrics.ts 的 param。m 是市場(twse 上市、tpex 上櫃),ind 是產業,q 是代號或名稱,s 是排序(參數.asc|desc),cols 是要顯示的欄位。src/lib/screener/presets.ts:存股、價值、成長、財務體質。daily_valuation 保留交易所公布的原始數字,零股和鉅額另外存在 odd_*、block_* 欄位,在計算快照時才相減。backfill --steps prices 回補:上市用 MI_INDEX、上櫃用 otc,都可以指定日期查詢;證交所的 STOCK_DAY_ALL 會忽略日期參數,所以不能拿來回補。<head> 的內嵌腳本(src/lib/theme.ts)會在第一次繪製前套用 .dark,不會閃爍,頁面也能維持靜態產生。vercel.json 指定為 syd1(雪梨),跟 Supabase(ap-southeast-2)放在同一區,資料庫查詢就不用跨洲。頁面本身由 Vercel 的全球 CDN 提供,台灣的使用者會從香港節點取得。NEXT_PUBLIC_SUPABASE_URL、NEXT_PUBLIC_SUPABASE_ANON_KEY、REVALIDATE_SECRET、NEXT_PUBLIC_SITE_URL。不需要 service role key。REVALIDATE_SECRET 要跟 Vercel 上的一樣;Variable 設 NEXT_PUBLIC_SITE_URL。POST /api/revalidate:ETL 寫入快照後呼叫,用 revalidateTag(tag, 'max') 讓選股頁與個股頁在背景重新產生。
Authorization: Bearer <REVALIDATE_SECRET>,比對時使用 timingSafeEqual。{"tags": ["snapshot"]},只接受 snapshot 或 stock:XXXX。etl/tests/test_snapshot_job.py 有測試涵蓋):
screen_snapshot,data_freshness 也不更新,網站繼續顯示上一份正確的資料。etl_runs.error,有完整的 traceback。lib/screener/codec.ts),HTML 從 1.4 MB 降到 0.36 MB。@vercel/analytics/next 的 <Analytics />。不使用 cookie,所以不需要 cookie 同意視窗;隱私權政策已寫明。/stocks/[code] 一個 route,也看得到各檔的 path。/api/cron/health)Vercel Cron 每天台灣時間 10:17 呼叫一次(vercel.json),跟 GitHub Actions 完全獨立:
data_freshness。就算 GitHub 排程停了,資料庫每天仍有活動,免費專案不會因為閒置 7 天被暫停。GITHUB_ACTIONS_TOKEN 時):每天讀取一次 etl.yml 的狀態,這也順便驗證權杖還有效。
disabled_inactivity)時,立刻重新啟用,資料不會出現空窗。daily,完成後會再呼叫 /api/revalidate 更新網站。72 小時的門檻用的是 ETL 的實際執行時間,所以週末(最長約 50 小時)和連假都不會誤判。disabled_manually)的排程會被尊重,不會自動重新啟用。ok 會是 false,github.error 會寫出原因。Vercel 環境變數:
CRON_SECRET:必填,至少 32 字元。GITHUB_ACTIONS_TOKEN:選填。fine-grained personal access token,只授權這個 repo,權限選 Actions: Read and write。沒有設定時只會保持 Supabase 活躍,不會自動修復。手動測試:
curl -H "Authorization: Bearer <CRON_SECRET>" https://tw-stock-screener-fawn.vercel.app/api/cron/health
如果 Supabase 真的被暫停了,到 Supabase 後台按 Restore 即可。暫停期間不收費,資料也會保留。這裡刻意不用 Management API 自動恢復,因為那需要權限涵蓋整個帳號的權杖。
/stocks/[code])ensureStatic = 'navigation'。build 時用 generateStaticParams 預先產生市值前 100 大;其他股票第一次被造訪時產生並存進快取,之後都是靜態頁。實測第一次約 0.7 秒,之後約 0.08 秒。getStockDetail() 跟快照共用 snapshot 標籤,ETL 通知時兩者一起失效;另外每小時自動更新一次。sitemap.xml 列出全部約 1,980 檔。daily_prices(src/components/stock/daily-chart.tsx)。daily_valuation、daily_prices 給登入會員(RLS),未登入時用 API 也讀不到。backfill --steps prices --price-days 92 回補約 3 個月。TWT49U、上櫃 bulletin/exDailyQ,都可以查日期區間),把漲跌改成「收盤 − 除權息參考價」,快照的漲跌幅也跟著正確。抓取失敗只記錄警告,不影響其他資料。/watchlist):
/strategies):
/account):可以刪除帳號,自選股和策略會一起刪除。另有 /privacy 隱私權政策頁。src/lib/auth/session.ts 和 src/lib/member/。user_id = auth.uid(),user_id 由資料庫自動帶入,而且不能修改。數量上限由 trigger 檢查。/auth/callback 用網址上的登入碼換取 session,寫入 cookie 後導回原頁面。next 參數只接受站內路徑(src/lib/auth/redirect.ts)。security definer 的 delete_my_account() 函式,只能刪除呼叫者自己,所以網站伺服器不需要 service_role key。email、profile、openid。https://<project-ref>.supabase.co/auth/v1/callback。https://<正式網址>/** 和 http://localhost:3000/**。因為 callback 會帶 ?next=,所以要用 ** 萬用字元。Schema 放在 supabase/migrations/。不要在 Dashboard 直接改表,所有變更都要寫成新的 migration。
| 資料表 | 內容 | 權限 |
|---|---|---|
companies | 公司基本資料、市場別、產業、財報格式 | 公開唯讀 |
daily_valuation | 每日開高低收、成交量、本益比、殖利率、股價淨值比 | 只有登入會員能讀 |
daily_prices(view) | 日 K 線圖用,成交量已扣零股與鉅額,附除權息欄位 | 只有登入會員能讀 |
ex_rights | 除權息計算結果(類別、前一日收盤、參考價、權息值) | 只有登入會員能讀 |
monthly_revenue | 月營收、月增率、年增率、累計 | 公開唯讀 |
financials_quarterly | 季報(損益與現金流為單季值、資產負債為期末值) | 公開唯讀 |
dividends | 股利分派 | 公開唯讀 |
screen_snapshot | 選股快照,每檔一列,由 ETL 整批重算 | 公開唯讀 |
data_freshness | 各資料集最後更新時間 | 公開唯讀 |
etl_runs | ETL 執行紀錄 | 只有 service_role |
watchlist_items | 會員的自選股(每人最多 200 檔) | 只有本人(RLS) |
saved_screens | 會員儲存的選股條件(每人最多 50 個) | 只有本人(RLS) |
慣例:
12.34 代表 12.34%。NULL,不要存 0。例如金融業沒有毛利率。權限有兩層:
Supabase API 預設每次最多回傳 1,000 列(
max_rows),讀取完整快照(約 1,800 列)時要用.range()分頁。
python etl/scripts/verify_sources.py:應顯示 19/19 成功,並產生 etl/reports/sources.md。npm run check:lint、型別、格式、單元測試全部通過。npm run e2e:首頁、404、安全性標頭的 E2E 測試通過(桌面與手機兩種裝置)。cd etl && pytest && ruff check .:通過。python etl/scripts/check_supabase.py:URL、公開 key、機密 key 三項都顯示 ✅。npm run db:push:顯示 Remote database is up to date,代表 migrations 都已經套用。npm run db:status:3 個 migration 在 Local 和 Remote 兩欄都有出現,代表兩邊一致。
(supabase db diff 需要 Docker 建立 shadow database,本專案不使用。)npm run test:db:15 個測試通過,包括:
42501)etl_runsnpm run db:types 之後執行 git diff src/types/database.ts:沒有差異,代表型別和 schema 同步。npm run check && npm run build:通過。在 etl/ 目錄執行:
pytest:單元測試全部通過。涵蓋民國日期、千元換算、HTML 解析容錯、各產業財報欄位對應、累計轉單季、TTM、ROE、連續成長、股利、合理性檢查。ruff check . && ruff format --check .:通過。python -m tw_etl daily --dry-run:抓到約 1,980 家公司、約 1,980 筆行情估值。資料庫還沒有季報時,快照會被合理性檢查擋下(這是預期行為)。python -m tw_etl backfill:完成後在 Supabase 的 SQL Editor 執行:
select status, job, rows_written, finished_at from etl_runs order by id desc limit 10;
select count(*) from screen_snapshot; -- 約 1,900
select * from screen_snapshot where code in ('2330', '2881', '6488');
python -m tw_etl verify-margins:重組的累計毛利率、營益率,與官方「營益分析」比對,不一致比例 ≤ 1%。npm test:59 個單元與元件測試通過。涵蓋:
npm run e2e:桌面與手機各 8 項,使用真實快照。流程是「套用存股模板 → 改用 ROE 排序 → 匯出 CSV(筆數一致)→ 用同一個網址重新開啟,結果相同」,另外還測搜尋、範圍條件、全部清除、首頁策略卡片,以及手機版沒有整頁橫向捲動。npm run dev 後開啟 http://localhost:3000/screener ,套用任一模板、調整條件、複製網址到無痕視窗開啟,結果應該一樣。NEXT_PUBLIC_SUPABASE_URL、NEXT_PUBLIC_SUPABASE_ANON_KEY,build 時會預先渲染 /screener。npm test:65 個測試通過,新增個股頁計算函式的測試:單季比率、金融業不適用、營收序列、季配息加總。npm run e2e:30 項通過(桌面與手機各 15 項)。新增的項目:
npm run build && npm start):
curl -s -o /dev/null -w "%{http_code}
" http://localhost:3000/stocks/2330 # 200 curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/stocks/9999 # 404
4. Lighthouse(本機正式版):
- 個股頁:SEO 100、無障礙 100、最佳做法 100、效能 85
- 選股頁:SEO 100、無障礙 100、最佳做法 100、效能 76
### P5
在正式環境(https://tw-stock-screener-fawn.vercel.app )驗證:
1. **狀態碼與快取**
- 首頁、選股頁、預先產生的個股頁直接由 CDN 提供(HIT),約 0.1 秒。
- 沒預先產生的個股頁,第一次開啟約 0.7~1.2 秒,之後都是 HIT。
- `/stocks/9999` 回傳 404。
2. **函式區域**:任何會執行函式的請求,回應標頭 `x-vercel-id` 都應該是 `…::syd1::…`。
3. **正式網址**:sitemap、canonical、og:image、robots.txt 都使用正式網址。
4. **快取更新**:在 GitHub Actions 手動執行 ETL `snapshot`。完成後,選股頁 HTML 裡的 `data_freshness` 快照時間,應該要等於 `etl_runs` 最新一筆的 `finished_at`。實測:06:29:29 那次 ETL 完成後,頁面在幾秒內更新。
5. **revalidate 端點**:
- 不帶密鑰回 401。
- 帶正確密鑰回 200,之後選股頁先回應 STALE(背景重新產生中),約 10 秒後回到 HIT。
6. **ETL 失敗**:`etl/tests/test_snapshot_job.py` 驗證失敗時不寫入、不刪除快照。GitHub Actions 失敗時會寄 Email 給 repo 擁有者。
7. **正式環境 Lighthouse**:
| 頁面 | 手機效能 | 桌機效能 | 無障礙/最佳做法/SEO |
|---|---|---|---|
| 首頁 | 94 | 100 | 100/100/100 |
| 選股 | 88 | 100 | 100/100/100 |
| 個股 | 87~89 | 100 | 100/100/100 |
### P6
1. **單元測試**(`npm test`):瀏覽器端的 Supabase client 會換成記憶體版(`src/test/fake-supabase.ts`),不需要網路。涵蓋:
- 登入狀態、登出。
- 自選股的樂觀更新、失敗還原、數量上限。
- 我的策略的新增、改名、刪除。
- `next` 參數防止 open redirect。
- `/auth/callback` 的成功和各種失敗。
2. **RLS 整合測試**(`npm run test:db`,`tests/db/members.test.ts`):對真實的 Supabase 建立兩個測試帳號 A、B,驗證:
- A 和 B 互相看不到、改不到、刪不到對方的資料。
- 不能偽造 `user_id`。
- 匿名使用者無法存取。
- 數量上限和長度限制有效。
- `delete_my_account()` 只刪除自己,資料會一起刪除。
3. **E2E**(`npm run e2e`,`tests/e2e/member.spec.ts`):
- 先用 service_role 建立測試帳號,在 Node 端登入取得 session cookie,再放進瀏覽器。
- 實際操作:加入自選 → 自選股頁 → 移除;儲存策略 → 套用 → 刪除;登出;刪除帳號。
- 測試結束後會刪除測試帳號。
4. **手動測試**(正式網站):
- 用自己的 Google 帳號登入,確認頭像選單、自選股、我的策略都正常。
- 換一台裝置登入,確認資料同步。