mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
06d0a7a2ae
commit
29e416109c
7 changed files with 592 additions and 27 deletions
|
|
@ -35,7 +35,7 @@ pub struct OwnershipCmd {
|
|||
pub enum OwnershipCommand {
|
||||
#[command(
|
||||
about = "Discover the latest IDX-hosted ownership report URLs",
|
||||
after_help = "Examples:\n idx ownership discover --limit 1\n idx ownership discover --family all --limit 6\n\nUse this when no snapshot manifest is available and you need a direct PDF URL for `idx ownership import --url`."
|
||||
after_help = "Examples:\n idx ownership discover --limit 1\n idx ownership discover --family all --limit 6\n\nUse this when no snapshot manifest is available and you need a direct XLSX or PDF URL for `idx ownership import --url`."
|
||||
)]
|
||||
Discover(DiscoverArgs),
|
||||
#[command(
|
||||
|
|
@ -84,7 +84,7 @@ pub struct DiscoverArgs {
|
|||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Direct URL to a remote ownership PDF, typically from `ownership discover`.
|
||||
/// Direct URL to a remote above-1% XLSX or PDF, typically from `ownership discover`.
|
||||
#[arg(long)]
|
||||
pub url: Option<String>,
|
||||
/// Path to a local ownership file: above-1% XLSX or PDF (primary), ZIP/TXT archive (fallback).
|
||||
|
|
@ -999,13 +999,16 @@ fn resolve_import_input(args: &ImportArgs) -> Result<Option<ResolvedImportInput>
|
|||
|
||||
if let Some(url) = &args.url {
|
||||
let trimmed = url.trim();
|
||||
validate_import_url(trimmed)?;
|
||||
let target = cache_pdf_path(trimmed)?;
|
||||
download_pdf(trimmed, &target)?;
|
||||
let format = validate_import_url(trimmed)?;
|
||||
let target = cache_download_path(trimmed, format)?;
|
||||
match format {
|
||||
ImportInputFormat::Xlsx => download_xlsx(trimmed, &target)?,
|
||||
_ => download_pdf(trimmed, &target)?,
|
||||
}
|
||||
return Ok(Some(ResolvedImportInput {
|
||||
import_path: target,
|
||||
source_url: Some(trimmed.to_string()),
|
||||
format: ImportInputFormat::Pdf,
|
||||
format,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -1031,7 +1034,11 @@ fn detect_local_import_format(path: &Path) -> Result<ImportInputFormat, IdxError
|
|||
}
|
||||
}
|
||||
|
||||
fn cache_pdf_path(url: &str) -> Result<PathBuf, IdxError> {
|
||||
fn cache_download_path(url: &str, format: ImportInputFormat) -> Result<PathBuf, IdxError> {
|
||||
let extension = match format {
|
||||
ImportInputFormat::Xlsx => ".xlsx",
|
||||
_ => ".pdf",
|
||||
};
|
||||
let raw_dir = crate::cache::cache_dir()?.join("ownership").join("raw");
|
||||
fs::create_dir_all(&raw_dir).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||
|
||||
|
|
@ -1045,20 +1052,20 @@ fn cache_pdf_path(url: &str) -> Result<PathBuf, IdxError> {
|
|||
.to_string();
|
||||
|
||||
if file_name.is_empty() || file_name == "/" {
|
||||
file_name = format!("ksei-{}.pdf", Utc::now().timestamp());
|
||||
file_name = format!("ksei-{}{extension}", Utc::now().timestamp());
|
||||
}
|
||||
if !file_name.to_ascii_lowercase().ends_with(".pdf") {
|
||||
file_name.push_str(".pdf");
|
||||
if !file_name.to_ascii_lowercase().ends_with(extension) {
|
||||
file_name.push_str(extension);
|
||||
}
|
||||
|
||||
Ok(raw_dir.join(file_name))
|
||||
}
|
||||
|
||||
fn validate_import_url(url: &str) -> Result<(), IdxError> {
|
||||
fn validate_import_url(url: &str) -> Result<ImportInputFormat, IdxError> {
|
||||
let trimmed = url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(IdxError::InvalidInput(
|
||||
"ownership import --url accepts direct PDF URLs only".to_string(),
|
||||
"ownership import --url accepts direct XLSX or PDF URLs only".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -1068,11 +1075,14 @@ fn validate_import_url(url: &str) -> Result<(), IdxError> {
|
|||
.next()
|
||||
.unwrap_or(normalized.as_str());
|
||||
if normalized.ends_with(".pdf") {
|
||||
return Ok(());
|
||||
return Ok(ImportInputFormat::Pdf);
|
||||
}
|
||||
if normalized.ends_with(".xlsx") {
|
||||
return Ok(ImportInputFormat::Xlsx);
|
||||
}
|
||||
|
||||
Err(IdxError::InvalidInput(
|
||||
"ownership import --url accepts direct PDF URLs only; run `idx ownership discover` first to find the current supported attachment".to_string(),
|
||||
"ownership import --url accepts direct XLSX or PDF URLs only; run `idx ownership discover` first to find the current supported file".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -1108,6 +1118,32 @@ fn download_pdf(url: &str, target: &Path) -> Result<(), IdxError> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn download_xlsx(url: &str, target: &Path) -> Result<(), IdxError> {
|
||||
if is_idx_url(url) {
|
||||
return remote::download_idx_xlsx(url, target);
|
||||
}
|
||||
|
||||
let response = ureq::get(url)
|
||||
.header(
|
||||
"Accept",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/octet-stream,*/*;q=0.8",
|
||||
)
|
||||
.call()
|
||||
.map_err(|e| IdxError::Http(format!("failed to download XLSX: {e}")))?;
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.read_to_vec()
|
||||
.map_err(|e| IdxError::Http(format!("failed reading XLSX body: {e}")))?;
|
||||
remote::validate_xlsx_payload(&bytes)?;
|
||||
|
||||
fs::write(target, &bytes).map_err(|e| {
|
||||
IdxError::Io(format!(
|
||||
"failed writing cached XLSX {}: {e}",
|
||||
target.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_idx_url(url: &str) -> bool {
|
||||
let normalized = url.trim().to_ascii_lowercase();
|
||||
normalized.starts_with("https://www.idx.co.id/")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue