"""ITU website data fetching functions. Fetches organizational data from ITU web pages: - Meeting documents (contributions, TDs) - Question details and leadership - Study group structure - Working party management """ import pycurl import sys from io import BytesIO from urllib.parse import parse_qs, urlparse, unquote from bs4 import BeautifulSoup from common.models import ( ValueAndLink, TableRow, AElement, Column, Row, Table, Role, Question, WorkingParty, WorkItem, QuestionStructure, WorkingPartyStructure, StudyGroupStructure, ) debug = False def _extract_source_from_url(href, fallback_text): """Extract source name from URL 'source' parameter, with fallback to displayed text. The ITU website sometimes truncates displayed source names but keeps the full name in the URL's source filter parameter. """ if not href: return fallback_text try: parsed = urlparse(href) params = parse_qs(parsed.query) if 'source' in params and params['source']: return unquote(params['source'][0]) except Exception: pass return fallback_text # --------------------------------------------------------------------------- # Meeting documents # --------------------------------------------------------------------------- def get_documents(document_type=None, group=None, working_party=None, questions=None, start=None): """Fetch temporary documents from ITU meeting document system. Args: document_type: 'C' (Contributions), 'GEN', 'PLEN', or 'WP' group: Study group number (e.g. 17) working_party: Working party number questions: Single question number, list of numbers, or 'QALL' start: Start date string (YYYYMMDD) Returns: List of TableRow objects. """ # Normalize start date: accept both YYYYMMDD (8 chars) and YYMMDD (6 chars) if len(start) == 8: # YYYYMMDD -> extract YY and convert to YYMMDD year = int(start[2:4]) start = start[2:] # Strip first 2 digits (century) else: # YYMMDD format year = int(start[0:2]) period = str(int(year / 4) * 4 + 1) url_base = (f"https://www.itu.int/md/meetingdoc.asp?lang=en" f"&parent=T{period}-SG{group}-{start}-") type_map = {'C': 'C', 'GEN': 'TD-GEN', 'PLEN': 'TD-PLEN', 'WP': f'TD-WP{working_party}'} if document_type not in type_map: return None url_base += type_map[document_type] question_names = [] all_questions_name = f'QALL/{group}' if questions is not None: if isinstance(questions, list): for q in questions: question_names.append(f'Q{q}/{group}') elif isinstance(questions, int): url_base += f'&question=Q{questions}/{group}' else: url_base += f'&question=QALL/{group}' first = 0 table_rows = [] while True: url = f"{url_base}&PageLB={first}" response = _fetch_url(url, encoding='iso8859-2') tables = _parse_html_tables(response) nrows = 0 for table in tables: if not _is_document_table(table): continue nrows = len(table.rows) - 3 if nrows <= 0: break first += nrows for i in range(2, len(table.rows) - 1): row = table.rows[i] number = None rev = None title = None source = None related_questions = None for col_idx, column in enumerate(row.columns): if col_idx == 1: # Number if column.aElements: value = None if column.aElements[0].strongElements: value = column.aElements[0].strongElements[0] elif column.aElements[0].contents: value = _clean(column.aElements[0].get_text()) href = column.aElements[0].href number = ValueAndLink(value, href) if column.fontElements: rev = column.fontElements[0][0] elif col_idx == 2: # Title if column.contents: idx = column.contents[0].find("\r") title = column.contents[0][:idx] if idx >= 0 else column.contents[0] idx = title.find("[from ") if idx >= 0: title = title[:idx] elif col_idx == 3: # Source if column.aElements: href = column.aElements[0].href displayed_text = column.aElements[0].get_text() source_name = _extract_source_from_url(href, displayed_text) source = ValueAndLink(source_name, href) elif col_idx == 4: # Related questions related_questions = [ ValueAndLink(a.get_text(), a.href) for a in column.aElements ] # Filter by question if a list was provided selected = True if isinstance(questions, list): selected = False for q in (related_questions or []): if q.value == all_questions_name: selected = True break for qn in question_names: if qn == q.value: selected = True if selected: break if selected: table_rows.append(TableRow( number=number, rev=rev, title=title, source=source, questions=related_questions )) if nrows <= 0: break return table_rows # --------------------------------------------------------------------------- # Question details # --------------------------------------------------------------------------- def get_question(group=None, question=None, start=None): """Fetch question leadership data from the ITU LOQR page. Args: group: Study group number question: Question number start: Start date (YYYYMMDD) Returns: Question object with roles, or None. """ year = int(start[:4]) period = str(int((year - 1953) / 4)) url = (f"https://www.itu.int/net4/ITU-T/lists/loqr.aspx" f"?Group={group}&Period={period}") question_name = f'Q{question}/{group}' response = _fetch_url(url, encoding='utf-8') soup = BeautifulSoup(response, "html.parser") prefix = 'ContentPlaceHolder1_dtlRappQues_' selected_table = None first_row = None last_row = None for table in soup.find_all("table"): rows = table.find_all("tr") if not isinstance(rows, list): continue for i in range(len(rows)): row = rows[i] for td in row.find_all("td"): spans = td.find_all("span") if not (isinstance(spans, list) and spans): continue if 'id' not in spans[0].attrs: continue span_id = spans[0].attrs['id'] if span_id.startswith(prefix + 'lblQWP_') or i == len(rows) - 1: if selected_table is None: contents = spans[0].contents if (isinstance(contents, list) and contents and contents[0].startswith(question_name)): selected_table = table first_row = i else: last_row = i break if last_row is not None: break if last_row is not None: break if last_row is None: return None question_details = Question(group=group, question=question) rows = selected_table.find_all("tr") for i in range(first_row, last_row): row = rows[i] role = Role() for td in row.find_all("td"): spans = td.find_all("span") a_elements = td.find_all("a") for span in spans: if 'id' not in span.attrs or not span.contents: continue span_id = span.attrs['id'] content = span.contents[0] if span_id.startswith(prefix + 'lblQWP_'): idx1 = content.find('(WP') if idx1 >= 0: idx2 = content.find('/', idx1 + 3) if idx2 > 0: try: question_details.workingParty = int(content[idx1 + 3:idx2]) except ValueError: pass elif span_id.startswith(prefix + 'lblQuestion69_'): question_details.title = content elif span_id.startswith(prefix + 'lblFName_'): role.firstName = content elif span_id.startswith(prefix + 'lblLName_'): role.lastName = content elif span_id.startswith(prefix + 'lblRole_'): role.roleName = content elif span_id.startswith(prefix + 'lblCompany_'): role.company = content elif span_id.startswith(prefix + 'lblAddress_'): if len(span.contents) > 1: role.address = span.contents[-2] elif span_id.startswith(prefix + 'telLabel_'): role.tel = content elif span_id.startswith(prefix + 'lblEmail_'): for a_el in a_elements: if 'id' in a_el.attrs and a_el.attrs['id'].startswith(prefix + 'linkemail_'): if a_el.contents: role.email = a_el.contents[0].replace('[at]', '@') question_details.addRole(role) role = Role() return question_details # --------------------------------------------------------------------------- # Study group structure # --------------------------------------------------------------------------- def get_study_group(group=None, start=None): """Fetch the SG organizational hierarchy (WPs and Questions). Args: group: Study group number start: Start date string (YYYY/MM/DD) Returns: StudyGroupStructure object. """ year = int(start[:4]) period = str(int((year - 1953) / 4)) url = (f"https://www.itu.int/net4/ITU-T/lists/sgstructure.aspx" f"?Group={group}&Period={period}") response = _fetch_url(url, encoding='utf-8') soup = BeautifulSoup(response, "html.parser") sg = StudyGroupStructure(group=group) current_wp = None current_q = None for table in soup.find_all("table"): rows = table.find_all("tr") if not isinstance(rows, list): continue for i in range(1, len(rows)): row = rows[i] for td in row.find_all("td"): wp = None wp_title = None title = None question = None spans = td.find_all("span") if not (isinstance(spans, list) and spans): continue for span in spans: if 'id' not in span.attrs: continue span_id = span.attrs['id'] if 'lblQWP_' in span_id and question is None: strongs = span.find_all("strong") if strongs and strongs[0].contents: question = str(strongs[0].contents[0]) if question.startswith('Q'): # Check if this question is discontinued (text in parent row) parent_row = span.find_parent('tr') if parent_row and 'discontinued' in parent_row.get_text().lower(): current_q = None continue pos = question.find('/') number = int(question[1:pos]) current_q = QuestionStructure(number=number) if current_wp is not None: current_wp.questions.append(current_q) else: current_q = None elif 'lblBlk' in span_id and wp is None: strongs = span.find_all("strong") if strongs and strongs[0].contents: wp = str(strongs[0].contents[0]) if wp.startswith('WP'): pos = wp.find('/') number = int(wp[2:pos]) current_wp = WorkingPartyStructure(number=number) sg.workingParties.append(current_wp) else: current_wp = None elif 'lblQuestion' in span_id: strongs = span.find_all("strong") if not strongs and title is None: title = str(span.contents[0]) if current_q is not None: current_q.title = title if strongs and wp_title is None and strongs[0].contents: wp_title = str(strongs[0].contents[0]) if current_wp is not None: current_wp.title = wp_title return sg # --------------------------------------------------------------------------- # Working party management # --------------------------------------------------------------------------- def get_working_party(group=None, working_party=None, questions=None, start=None): """Fetch working party leadership data from ITU management page. Args: group: Study group number working_party: Working party number questions: List of question structures start: Start date string (YYYY/MM/DD) Returns: WorkingParty object with roles. """ wp_details = WorkingParty( group=group, workingParty=working_party, questions=questions ) wp_name = f"WP{working_party}/{group}" year = int(start[:4]) period = str(int((year - 1953) / 4)) url = (f"https://www.itu.int/net4/ITU-T/lists/mgmt.aspx" f"?Group={group}&Period={period}") response = _fetch_url(url, encoding='utf-8') soup = BeautifulSoup(response, "html.parser") for table in soup.find_all("table"): rows = table.find_all("tr") if not isinstance(rows, list): continue for i in range(1, len(rows)): row = rows[i] first_name = last_name = wp = title = None sub_role = company = address = tel = email = None for td in row.find_all("td"): for span in td.find_all("span"): if 'id' not in span.attrs or not span.contents: continue span_id = span.attrs['id'] for content in span.contents: if str(content) == '
': continue s = str(content) if "lblFName" in span_id: first_name = s elif "lblLName" in span_id: last_name = s elif "lblWP" in span_id: wp = s elif "lblTitle" in span_id: title = s elif "lblSubrole" in span_id: sub_role = s elif "lblCompany" in span_id: company = s elif "lblAddress" in span_id: address = s elif "lblTel" in span_id: tel = s for a_el in td.find_all("a"): if 'id' in a_el.attrs and "Email" in a_el.attrs['id']: email = str(a_el.contents[0]).replace('[at]', '@') if title and first_name and last_name: role = Role( roleName=title, firstName=first_name, lastName=last_name, company=company, address=address, email=email, tel=tel ) if wp is not None and wp.lower() == wp_name.lower(): wp_details.addRole(role) elif wp is None or wp.strip() == '': # SG-level roles (Counsellor, Project officer, etc.) wp_details.sg_roles.append(role) return wp_details # --------------------------------------------------------------------------- # Work programme # --------------------------------------------------------------------------- _WP_SEARCH_URL = "https://www.itu.int/ITU-T/workprog/wp_search.aspx" _TABULAR_HEADERS = [ 'Work item', 'Question', 'Equiv. Num.', 'Status', 'Timing', 'Approval process', 'Version', 'Liaison relationship', 'Subject/Title', 'Priority', ] def get_work_programme(group=None, question=None, working_party=None, start=None): """Fetch work programme items from the public ITU work programme page. Discovers ISN parameters dynamically, then scrapes the tabular view. Args: group: Study group number (e.g. 17) question: Question number (e.g. 11), or None for all questions working_party: Working party number (e.g. 1) start: Start date string (YYYY/MM/DD) Returns: List of WorkItem objects, or empty list on error. """ year = int(start[:4]) # Calculate study period range (e.g., 2026 -> "2025-2028") # Study periods: 2001-2004, 2005-2008, ..., 2017-2020, 2022-2024, 2025-2028 if year >= 2025: period_start = 2025 + ((year - 2025) // 4) * 4 elif year >= 2022: period_start = 2022 # Special case: 2022-2024 (3-year period) elif year >= 2017: period_start = 2017 else: period_start = 2001 + ((year - 2001) // 4) * 4 study_period_prefix = f"{period_start}-" # Step 1: Discover Study Period and Study Group ISNs html = _fetch_url(_WP_SEARCH_URL, encoding='utf-8') soup = BeautifulSoup(html, 'html.parser') isn_sp = _find_dropdown_isn(soup, 'study_period', study_period_prefix) isn_sg = _find_dropdown_isn(soup, 'study_group', f'SG{group}:') if isn_sp is None or isn_sg is None: print(f"Could not find ISN for study period {study_period_prefix}* or SG{group}") return [] # Step 2: Discover WP and Question ISNs url2 = f"{_WP_SEARCH_URL}?isn_sp={isn_sp}&isn_sg={isn_sg}" html2 = _fetch_url(url2, encoding='utf-8') soup2 = BeautifulSoup(html2, 'html.parser') isn_wp = _find_dropdown_isn(soup2, 'working_party', f'WP{working_party}/{group}:') if isn_wp is None: print(f"Could not find ISN for WP{working_party}/{group}") return [] # Question is optional: use -1 for "any question" (all questions under the WP) isn_qu = '-1' if question is not None: isn_qu = _find_dropdown_isn(soup2, 'question', f'Q{question}/{group}:') if isn_qu is None: print(f"Could not find ISN for Q{question}/{group}") return [] # Step 3: Fetch with all ISNs and parse the tabular view table # Use full URL with all statuses and larger page size to get all items url3 = (f"{_WP_SEARCH_URL}?isn_sp={isn_sp}&isn_sg={isn_sg}" f"&isn_wp={isn_wp}&isn_qu={isn_qu}" f"&isn_status=-1,8,1,3,7,2,4,6,5&pg_size=100&details=0&field=acdefghijo") print(f" Fetching work programme from: {url3}") html3 = _fetch_url(url3, encoding='utf-8') soup3 = BeautifulSoup(html3, 'html.parser') work_items = _parse_work_programme_table(soup3) # Extract detail page links from the entire page (wp_item.aspx?isn=X) # and match them to work items by name detail_links = {} for a_tag in soup3.find_all('a'): href = a_tag.get('href', '') if 'wp_item.aspx?isn=' in href: text = a_tag.get_text().strip() if text: detail_links[text] = href if detail_links: print(f" Found {len(detail_links)} detail page links: {list(detail_links.keys())[:5]}...") else: print(f" No detail page links found on search results page") matched = 0 for wi in work_items: if not wi.detailLink and wi.workItem: wi.detailLink = detail_links.get(wi.workItem, '') if wi.detailLink: matched += 1 if detail_links: print(f" Matched {matched}/{len(work_items)} work items to detail links") return work_items def _find_dropdown_isn(soup, dropdown_name, match_prefix): """Find an ISN value from a