# install.packages(c("tidyverse", "rvest", "httr", "jsonlite"))  # run once if missing
library(tidyverse)
library(rvest)
library(httr)
library(jsonlite)

Part 1: TWSE ISIN page (strMode = 2)

url_isin <- "https://isin.twse.com.tw/isin/C_public.jsp?strMode=2"

res <- GET(url_isin, config(ssl_verifypeer = FALSE))

# convert the raw bytes to UTF-8 ourselves; sub = "?" stops rare characters from causing an error
html_txt <- iconv(rawToChar(content(res, as = "raw")),
                  from = "BIG5-HKSCS", to = "UTF-8", sub = "?")

page <- read_html(html_txt, encoding = "UTF-8")
tbls <- page |> html_elements("table") |> html_table(header = FALSE)
isin_raw <- tbls[[which.max(map_int(tbls, nrow))]]

names(isin_raw) <- c("code_name", "isin_code", "listing_date",
                     "market", "industry", "cfi_code", "note")
head(isin_raw)
## # A tibble: 6 × 7
##   code_name          isin_code       listing_date market industry cfi_code note 
##   <chr>              <chr>           <chr>        <chr>  <chr>    <chr>    <chr>
## 1 有價證券代號及名稱 國際證券辨識號碼(ISIN … 上市日       市場別 產業別   CFICode  "備註"…
## 2 股票               股票            股票         股票   股票     股票     "股票"…
## 3 1101 台泥         TW0001101004    1962/02/09   上市   水泥工業 ESVUFR   ""   
## 4 1102 亞泥         TW0001102002    1962/06/08   上市   水泥工業 ESVUFR   ""   
## 5 1103 嘉泥         TW0001103000    1969/11/14   上市   水泥工業 ESVUFR   ""   
## 6 1104 環泥         TW0001104008    1971/02/01   上市   水泥工業 ESVUFR   ""

The page mixes several security types (stocks, ETFs, warrants, preferred shares, etc.), each under its own title row. I tag every row with its section first.

isin_tagged <- isin_raw |>
  slice(-1) |>                                   # drop the column-title row
  mutate(section = if_else(code_name == isin_code, code_name, NA_character_)) |>
  fill(section, .direction = "down") |>          # copy the title down to its rows
  filter(code_name != isin_code)                 # remove the title rows

count(isin_tagged, section)
## # A tibble: 8 × 2
##   section                     n
##   <chr>                   <int>
## 1 ETF                       241
## 2 ETN                        15
## 3 上市認購(售)權證        36927
## 4 創新板                     31
## 5 受益證券-不動產投資信託     6
## 6 特別股                     27
## 7 股票                     1054
## 8 臺灣存託憑證(TDR)          10

To keep common stocks only, I keep rows whose CFI code starts with “ES” (equity shares) and whose code is exactly 4 digits. This removes ETFs, ETNs, warrants, TDRs and preferred shares (preferred shares have letters in their code, like 2882A). The 31 Innovation Board stocks are also on the strMode=2 page and are common stocks, so they stay in.

twse_isin <- isin_tagged |>
  separate(code_name, into = c("stock_id", "stock_name"),
           sep = "\u3000", extra = "merge", fill = "right") |>
  filter(str_starts(cfi_code, "ES"),
         str_detect(stock_id, "^[0-9]{4}$")) |>
  transmute(stock_id,
            stock_name   = str_squish(stock_name),
            listing_date = ymd(listing_date),
            industry)

glimpse(twse_isin)
## Rows: 1,085
## Columns: 4
## $ stock_id     <chr> "1101", "1102", "1103", "1104", "1108", "1109", "1110", "…
## $ stock_name   <chr> "台泥", "亞泥", "嘉泥", "環泥", "幸福", "信大", "東泥", "味全", "味王", "大成…
## $ listing_date <date> 1962-02-09, 1962-06-08, 1969-11-14, 1971-02-01, 1990-06-…
## $ industry     <chr> "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "…
nrow(twse_isin)
## [1] 1085
write_excel_csv(twse_isin, "twse_isin_listed.csv")

Part 2: FinMind

FinMind’s TaiwanStockInfo has no real listing date. Its date column is just the last update date of the record, so I call it snapshot_date. Only the ISIN list has true listing dates.

fm_url <- "https://api.finmindtrade.com/api/v4/data?dataset=TaiwanStockInfo"
# if the API asks for a token: fm_url <- paste0(fm_url, "&token=YOUR_TOKEN")

fm_raw <- fromJSON(fm_url)$data |> as_tibble()

glimpse(fm_raw)
## Rows: 4,333
## Columns: 5
## $ industry_category <chr> "電子零組件業", "電腦及週邊設備業", "光電業", "文化創意業", "其他電子類", "上櫃指數…
## $ stock_id          <chr> "5481", "5450", "3629", "3687", "6238", "00835B", "0…
## $ stock_name        <chr> "新華", "寶聯通", "地心引力", "歐買尬", "勝麗", "第一金科技債10+", "永豐15…
## $ type              <chr> "tpex", "tpex", "tpex", "tpex", "tpex", "tpex", "tpe…
## $ date              <chr> "2020-06-03", "2020-06-03", "2020-06-03", "2020-06-0…
count(fm_raw, type)
## # A tibble: 3 × 2
##   type         n
##   <chr>    <int>
## 1 emerging   553
## 2 tpex      1378
## 3 twse      2402
fm_stocks <- fm_raw |>
  filter(type == "twse",
         str_detect(stock_id, "^[0-9]{4}$"),
         !str_detect(industry_category,
                     "ETF|ETN|Index|大盤|存託憑證|受益證券")) |>
  arrange(stock_id, industry_category == "電子工業", desc(date)) |>
  distinct(stock_id, .keep_all = TRUE) |>
  select(stock_id, stock_name, industry = industry_category, snapshot_date = date)

glimpse(fm_stocks)
## Rows: 1,215
## Columns: 4
## $ stock_id      <chr> "1101", "1102", "1103", "1104", "1107", "1108", "1109", …
## $ stock_name    <chr> "台泥", "亞泥", "嘉泥", "環泥", "建台", "幸福", "信大", "東泥", "味全", "味…
## $ industry      <chr> "水泥工業", "水泥工業", "水泥工業", "水泥工業", "其他", "水泥工業", "水泥工業", "水…
## $ snapshot_date <chr> "2026-10-12", "2026-10-12", "2026-10-12", "2026-10-12", …
nrow(fm_stocks)
## [1] 1215
count(fm_stocks, snapshot_date, sort = TRUE) |> head(10)   # how old are the records?
## # A tibble: 10 × 2
##    snapshot_date     n
##    <chr>         <int>
##  1 2026-10-12     1083
##  2 2024-12-04       65
##  3 2026-06-13       11
##  4 2026-07-06        7
##  5 2024-12-03        6
##  6 2026-06-30        6
##  7 2026-07-03        5
##  8 2026-07-01        4
##  9 2026-07-07        3
## 10 2026-07-08        3

FinMind keeps old records of stocks that are no longer listed (their snapshot dates are old). To remove them without using the ISIN list, I keep only records from the latest snapshot date.

fm_stocks <- fm_stocks |>
  filter(snapshot_date == max(snapshot_date))

nrow(fm_stocks)
## [1] 1083
write_excel_csv(fm_stocks, "finmind_listed.csv")

Comparison

only_isin <- anti_join(twse_isin, fm_stocks, by = "stock_id")   # in ISIN but not in FinMind
only_fm   <- anti_join(fm_stocks, twse_isin, by = "stock_id")   # in FinMind but not in ISIN

nrow(only_isin)
## [1] 2
nrow(only_fm)
## [1] 0
only_isin |> arrange(desc(listing_date))
## # A tibble: 2 × 4
##   stock_id stock_name listing_date industry  
##   <chr>    <chr>      <date>       <chr>     
## 1 4155     訊映       2017-12-05   生技醫療業
## 2 1589     永冠-KY    2012-04-27   電機機械
only_fm
## # A tibble: 0 × 4
## # ℹ 4 variables: stock_id <chr>, stock_name <chr>, industry <chr>,
## #   snapshot_date <chr>
fm_raw |> filter(stock_id %in% only_isin$stock_id)
## # A tibble: 3 × 5
##   industry_category stock_id stock_name type  date      
##   <chr>             <chr>    <chr>      <chr> <chr>     
## 1 電機機械          1589     永冠-KY    twse  2026-07-04
## 2 化學生技醫療      4155     訊映       twse  2026-10-07
## 3 生技醫療業        4155     訊映       twse  2026-10-07
nrow(twse_isin)
## [1] 1085
nrow(fm_stocks)
## [1] 1083
setequal(twse_isin$stock_id, fm_stocks$stock_id)
## [1] FALSE

The two sources agree almost perfectly: ISIN has 1,085 common stocks and FinMind has 1,083. The 2 stocks that appear only in the ISIN list are 1589 (永冠-KY) and 4155 (訊映). The check above shows that FinMind does contain both, classified as twse, but their records have older snapshot dates (2026-07-04 and 2026-10-07) than the latest snapshot (2026-10-12), so the latest-date filter removed them. Stock 4155 also appears twice in FinMind with two different industry labels. Since ISIN is the official source and still lists both stocks, I treat the ISIN list of 1,085 as the reference, and the 2-stock difference comes from stale FinMind records, not from different stock universes.