feat(ownership): discover above-1% XLSX on the IDX data page

IDX stopped posting the monthly above-1% list as an announcement after
the 2026-05-29 report; June/July/August are only on the Data
Kepemilikan Saham page. `ownership discover` could not see them, and
IDX's Cloudflare now rejects curl_chrome142 on that page and on the
/Media/ XLSX files.

- remote: parse the page's Nuxt payload (Description/Prospectus pairs,
  / escapes), classify above-1% XLSX as supported and above-5% /
  investor-type workbooks as unsupported, derive as_of_date from the
  Indonesian description. Keep all above-1% entries, only the newest
  of the other series. Merge with the announcement results.
- discover JSON gains `format` (xlsx|pdf) and `as_of_date`; page
  entries use the as-of date as publish_date (the page shows none).
- import --url accepts .xlsx and downloads via the XLSX validator.
- curl_impersonate: run_owned_with_fallback tries several browser
  profiles until the body validates (JSON / page payload / %PDF / PK);
  IDX_CURL_IMPERSONATE_BIN still pins one.
- Tests: page fixture parsing, date parsing, payload validation, CLI
  discover via a local page server; existing discover tests now point
  IDX_OWNERSHIP_DATA_PAGE_URL at a closed port so no test hits IDX.
- Docs: ARCHITECTURE discovery section, AGENTS ownership inputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017a5Cv5Z1NzdYKgKnTiAF4E
This commit is contained in:
hermes 2026-09-24 20:55:20 +00:00
commit 29e416109c
7 changed files with 592 additions and 27 deletions

View file

@ -1189,6 +1189,43 @@ fn ownership_releases_uses_xdg_data_home_default_db_path() {
);
}
/// Keeps discovery tests off the live IDX data page (nothing listens on port 9).
const UNREACHABLE_DATA_PAGE_URL: &str = "http://127.0.0.1:9/data-kepemilikan-saham/";
#[test]
fn ownership_discover_finds_xlsx_on_share_ownership_page() {
let page = fs::read_to_string("tests/fixtures/idx_share_ownership_page_excerpt.html")
.expect("read share ownership page fixture");
let page_url = spawn_single_response_server("text/html", page);
let output = test_bin("ownership-discover-data-page")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env(
"IDX_OWNERSHIP_ANNOUNCEMENT_API_URL",
"http://127.0.0.1:9/announcements",
)
.env("IDX_OWNERSHIP_DATA_PAGE_URL", &page_url)
.args(["-o", "json", "ownership", "discover", "--limit", "1"])
.output()
.expect("ownership discover json output");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let reports: Value = serde_json::from_slice(&output.stdout).expect("parse discover json");
let latest = &reports[0];
assert_eq!(latest["family"], "above_one_percent");
assert_eq!(latest["status"], "supported");
assert_eq!(latest["format"], "xlsx");
assert_eq!(latest["as_of_date"], "2026-08-31");
assert_eq!(
latest["pdf_url"],
"https://www.idx.co.id/Media/fahlw1o2/peng-2026-08-00017-satu-persen.xlsx"
);
}
#[test]
fn ownership_discover_lists_fixture_candidates() {
let body = fs::read_to_string("tests/fixtures/idx_announcement_kepemilikan.json")
@ -1198,6 +1235,7 @@ fn ownership_discover_lists_fixture_candidates() {
test_bin("ownership-discover")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
.env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL)
.env(
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
"http://127.0.0.1/pengumuman",
@ -1257,6 +1295,7 @@ fn ownership_discover_supports_above1_family() {
test_bin("ownership-discover-above1")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
.env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL)
.env(
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
"http://127.0.0.1/pengumuman",
@ -1313,6 +1352,7 @@ fn ownership_discover_defaults_to_above1_and_prefers_supported_attachment() {
let output = test_bin("ownership-discover-default")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
.env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL)
.env(
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
"http://127.0.0.1/pengumuman",
@ -1367,6 +1407,7 @@ fn ownership_discover_json_includes_status() {
test_bin("ownership-discover-json-status")
.env("IDX_CURL_IMPERSONATE_BIN", "curl")
.env("IDX_OWNERSHIP_ANNOUNCEMENT_API_URL", &json_url)
.env("IDX_OWNERSHIP_DATA_PAGE_URL", UNREACHABLE_DATA_PAGE_URL)
.env(
"IDX_OWNERSHIP_ANNOUNCEMENT_PAGE_URL",
"http://127.0.0.1/pengumuman",
@ -1406,7 +1447,7 @@ fn ownership_import_url_rejects_listing_page_inputs() {
.assert()
.failure()
.stderr(predicate::str::contains(
"ownership import --url accepts direct PDF URLs only",
"ownership import --url accepts direct XLSX or PDF URLs only",
))
.stderr(predicate::str::contains("ownership discover"));
}

View file

@ -0,0 +1,5 @@
<!doctype html>
<html lang="id"><head><title>Data Kepemilikan Saham - Bursa Efek Indonesia</title></head>
<body><div id="__nuxt"><!-- trimmed excerpt of https://www.idx.co.id/id/perusahaan-tercatat/data-kepemilikan-saham/ (2026-09-24) --></div>
<script>window.__NUXT__=(function(a,r){return {fetch:{"data-v-8c8e470a:0":{tableData:[{Description:"Pemegang Saham di Atas 5% per 23 September 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Fsucngcwy\u002Fpeng-2026-09-23-00080-lima-persen.xlsx",ListingDate:"2026-09-23T12:00:00"},{Description:"Pemegang Saham di Atas 5% per 31 Agustus 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Fumrls4il\u002Fpeng-2026-08-31-00063-lima-persen.xlsx",ListingDate:r},{Description:"Data Kepemilikan Saham Perusahaan Tercatat Berdasarkan Klasifikasi Investor per 31 Agustus 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Fy0vjakdz\u002Fpeng-2026-08-00018-klasifikasi.xlsx",ListingDate:r},{Description:"Data Kepemilikan Saham Perusahaan Tercatat per Tipe Investor per 31 Agustus 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Fwkjfudt3\u002Fpeng-2026-08-00019-tipe.xlsx",ListingDate:r},{Description:"Pemegang Saham di Atas 1% per 31 Agustus 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Ffahlw1o2\u002Fpeng-2026-08-00017-satu-persen.xlsx",ListingDate:r},{Description:"Pemegang Saham di Atas 1% per 31 Juli 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Ffjelt0en\u002Fpeng-2026-07-00016-satu-persen.xlsx",ListingDate:s},{Description:"Pemegang Saham di Atas 1% per 27 Februari 2026",Prospectus:"https:\u002F\u002Fwww.idx.co.id\u002FMedia\u002Fr2kjzpk5\u002Fpeng-02-00011-satu-persen.xlsx",ListingDate:K}]}}}}(null,"2026-09-01T12:00:00"));</script>
</body></html>