Simon commited on
Commit
09d041b
·
1 Parent(s): c562ed5

Simon/extract urls from domain (#6)

Browse files

* Add sitemap parsing utilities and example script for URL extraction

* Add script to extract URLs from legitimate domains using sitemaps and save to CSV

* addade more urls to csv

* Add unit tests for sitemap parser and URL extraction functions

examples/extract_urls_from_sitemaps.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract URLs from legitimate domains using sitemaps and save to CSV."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ import pandas as pd
7
+ from datetime import datetime
8
+ import json
9
+
10
+ # Add src directory to Python path
11
+ project_root = Path(__file__).parent.parent
12
+ sys.path.insert(0, str(project_root / "src"))
13
+
14
+ from phising_detection.data.sitemap_parser import get_urls_from_sitemap
15
+
16
+ # Configure logging
17
+ logging.basicConfig(
18
+ level=logging.INFO,
19
+ format='%(asctime)s - %(levelname)s - %(message)s'
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def get_already_processed_domains(csv_file: Path) -> set:
25
+ """
26
+ Get set of domains that have already been processed.
27
+
28
+ Args:
29
+ csv_file: Path to the CSV file with processed results
30
+
31
+ Returns:
32
+ Set of domain names that have been processed
33
+ """
34
+ if not csv_file.exists():
35
+ return set()
36
+
37
+ try:
38
+ df = pd.read_csv(csv_file)
39
+ if 'domain' in df.columns:
40
+ return set(df['domain'].unique())
41
+ except Exception as e:
42
+ logger.warning(f"Error reading existing CSV: {e}")
43
+
44
+ return set()
45
+
46
+
47
+ def save_batch_to_csv(batch_data: list, csv_file: Path):
48
+ """
49
+ Save a batch of domain data to CSV.
50
+
51
+ Args:
52
+ batch_data: List of dictionaries with keys: domain, urls, time_updated
53
+ csv_file: Path to CSV file
54
+ """
55
+ if not batch_data:
56
+ return
57
+
58
+ df = pd.DataFrame(batch_data)
59
+
60
+ # Convert URL lists to JSON strings for CSV storage
61
+ df['urls'] = df['urls'].apply(json.dumps)
62
+
63
+ # Write header only if creating new file
64
+ header = not csv_file.exists()
65
+
66
+ df.to_csv(csv_file, mode='a', header=header, index=False)
67
+ logger.info(f"Saved batch of {len(batch_data)} domains to {csv_file}")
68
+
69
+
70
+ def main():
71
+ """Extract URLs from legitimate domains and save to CSV incrementally."""
72
+
73
+ # Paths
74
+ data_dir = Path(__file__).parent.parent / "src" / "phising_detection" / "data" / "data_files"
75
+ domains_file = data_dir / "legitimate-urls.txt"
76
+ output_csv = data_dir / "legitimate-urls-extracted.csv"
77
+
78
+ # Read domains from file
79
+ logger.info(f"Reading domains from {domains_file}")
80
+ with open(domains_file, 'r', encoding='utf-8') as f:
81
+ all_domains = [line.strip() for line in f if line.strip()]
82
+
83
+ logger.info(f"Loaded {len(all_domains)} total domains")
84
+
85
+ # Check which domains have already been processed
86
+ processed_domains = get_already_processed_domains(output_csv)
87
+ logger.info(f"Already processed: {len(processed_domains)} domains")
88
+
89
+ # Filter out already processed domains
90
+ domains_to_process = [d for d in all_domains if d not in processed_domains]
91
+ logger.info(f"Remaining to process: {len(domains_to_process)} domains")
92
+
93
+ if not domains_to_process:
94
+ logger.info("All domains have already been processed!")
95
+ return
96
+
97
+ # Configuration
98
+ max_urls_per_domain = 10
99
+ batch_size = 50 # Save every 50 domains
100
+ timeout = 10
101
+ delay_between_domains = 0.5
102
+
103
+ # Process domains in batches
104
+ total_processed = 0
105
+ total_urls_extracted = 0
106
+ domains_with_urls = 0
107
+ batch_data = []
108
+
109
+ for i, domain in enumerate(domains_to_process):
110
+ logger.info(f"Processing {i+1}/{len(domains_to_process)}: {domain}")
111
+
112
+ try:
113
+ # Get current timestamp
114
+ time_updated = datetime.now().isoformat()
115
+
116
+ # Extract URLs from sitemap
117
+ urls = get_urls_from_sitemap(
118
+ domain,
119
+ max_urls=max_urls_per_domain,
120
+ timeout=timeout
121
+ )
122
+
123
+ logger.info(f" Found {len(urls)} URLs from {domain}")
124
+
125
+ # Add domain to batch data (even if no URLs found)
126
+ batch_data.append({
127
+ 'domain': domain,
128
+ 'urls': urls, # Will be converted to JSON in save function
129
+ 'time_updated': time_updated
130
+ })
131
+
132
+ total_urls_extracted += len(urls)
133
+ if urls:
134
+ domains_with_urls += 1
135
+ total_processed += 1
136
+
137
+ # Save batch every N domains
138
+ if (i + 1) % batch_size == 0:
139
+ save_batch_to_csv(batch_data, output_csv)
140
+ logger.info(f"Checkpoint: Processed {total_processed} domains, {domains_with_urls} with URLs, {total_urls_extracted} total URLs")
141
+ batch_data = []
142
+
143
+ # Small delay to be polite
144
+ import time
145
+ time.sleep(delay_between_domains)
146
+
147
+ except KeyboardInterrupt:
148
+ logger.info("\nInterrupted by user. Saving current batch...")
149
+ if batch_data:
150
+ save_batch_to_csv(batch_data, output_csv)
151
+ logger.info(f"Saved progress. Processed {total_processed} domains so far.")
152
+ return
153
+
154
+ except Exception as e:
155
+ logger.error(f"Error processing {domain}: {e}")
156
+ # Still save the domain with empty URL list
157
+ batch_data.append({
158
+ 'domain': domain,
159
+ 'urls': [],
160
+ 'time_updated': datetime.now().isoformat()
161
+ })
162
+ total_processed += 1
163
+ continue
164
+
165
+ # Save any remaining data
166
+ if batch_data:
167
+ save_batch_to_csv(batch_data, output_csv)
168
+
169
+ # Print final summary
170
+ logger.info("\n=== Final Summary ===")
171
+ logger.info(f"Domains processed this run: {total_processed}")
172
+ logger.info(f"Domains with URLs this run: {domains_with_urls}")
173
+ logger.info(f"Total URLs extracted this run: {total_urls_extracted}")
174
+ if total_processed > 0:
175
+ logger.info(f"Average URLs per domain: {total_urls_extracted / total_processed:.1f}")
176
+
177
+ # Show overall statistics from CSV
178
+ if output_csv.exists():
179
+ df = pd.read_csv(output_csv)
180
+
181
+ # Parse URL lists from JSON
182
+ df['urls_parsed'] = df['urls'].apply(json.loads)
183
+ df['url_count'] = df['urls_parsed'].apply(len)
184
+
185
+ total_urls = df['url_count'].sum()
186
+ domains_with_urls_total = (df['url_count'] > 0).sum()
187
+
188
+ logger.info(f"\n=== Overall Statistics ===")
189
+ logger.info(f"Total domains processed: {len(df)}")
190
+ logger.info(f"Domains with URLs: {domains_with_urls_total}")
191
+ logger.info(f"Domains without URLs: {len(df) - domains_with_urls_total}")
192
+ logger.info(f"Total URLs collected: {total_urls}")
193
+ if domains_with_urls_total > 0:
194
+ logger.info(f"Average URLs per domain (with URLs): {total_urls / domains_with_urls_total:.1f}")
195
+
196
+ logger.info(f"\n=== Sample Data ===")
197
+ for _, row in df.head(5).iterrows():
198
+ url_list = json.loads(row['urls'])
199
+ url_count = len(url_list)
200
+ logger.info(f"\n{row['domain']} (updated: {row['time_updated']})")
201
+ if url_count > 0:
202
+ logger.info(f" {url_count} URLs:")
203
+ for url in url_list[:3]:
204
+ logger.info(f" - {url}")
205
+ if url_count > 3:
206
+ logger.info(f" ... and {url_count - 3} more")
207
+ else:
208
+ logger.info(f" No URLs found (empty sitemap or no sitemap)")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
examples/load_phishing_urls_example.py CHANGED
@@ -10,7 +10,7 @@ sys.path.insert(0, str(project_root / "src"))
10
  from phising_detection.data import load_phishing_urls
11
 
12
  # Load phishing URLs from the file
13
- df = load_phishing_urls("../src/phising_detection/data/phishing-links-ACTIVE.txt")
14
 
15
  # Display basic information
16
  print(f"Loaded {len(df)} phishing URLs")
 
10
  from phising_detection.data import load_phishing_urls
11
 
12
  # Load phishing URLs from the file
13
+ df = load_phishing_urls("../src/phising_detection/data/data_files/phishing-links-ACTIVE.txt")
14
 
15
  # Display basic information
16
  print(f"Loaded {len(df)} phishing URLs")
src/phising_detection/data/phishing-links-ACTIVE.txt DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:9be95a739bde7285f30c7477947f0eaa832a0ad94e0ea197fec81702a7e43036
3
- size 65832907
 
 
 
 
src/phising_detection/data/sitemap_parser.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sitemap parsing utilities for extracting URLs from domains."""
2
+
3
+ import requests
4
+ import xml.etree.ElementTree as ET
5
+ from typing import List, Optional, Set
6
+ from urllib.parse import urljoin
7
+ import time
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def get_urls_from_sitemap(
14
+ domain: str,
15
+ max_urls: Optional[int] = None,
16
+ timeout: int = 10,
17
+ max_depth: int = 2
18
+ ) -> List[str]:
19
+ """
20
+ Extract URLs from a domain's sitemap.xml.
21
+
22
+ Args:
23
+ domain: Domain name (e.g., 'google.com' or 'www.google.com')
24
+ max_urls: Maximum number of URLs to return (None for all)
25
+ timeout: Request timeout in seconds
26
+ max_depth: Maximum depth for nested sitemaps (sitemap index files)
27
+
28
+ Returns:
29
+ List of URLs found in the sitemap
30
+ """
31
+ urls: Set[str] = set()
32
+
33
+ # Try common sitemap locations
34
+ sitemap_urls = _get_sitemap_urls(domain)
35
+
36
+ for sitemap_url in sitemap_urls:
37
+ try:
38
+ logger.info(f"Fetching sitemap: {sitemap_url}")
39
+ response = requests.get(sitemap_url, timeout=timeout, headers={
40
+ 'User-Agent': 'Mozilla/5.0 (compatible; URLCollector/1.0)'
41
+ })
42
+
43
+ if response.status_code == 200:
44
+ extracted = _parse_sitemap(
45
+ response.content,
46
+ max_urls - len(urls) if max_urls else None,
47
+ timeout,
48
+ max_depth
49
+ )
50
+ urls.update(extracted)
51
+ logger.info(f"Found {len(extracted)} URLs from {sitemap_url}")
52
+
53
+ if max_urls and len(urls) >= max_urls:
54
+ break
55
+ else:
56
+ logger.debug(f"Failed to fetch {sitemap_url}: {response.status_code}")
57
+
58
+ except requests.RequestException as e:
59
+ logger.debug(f"Error fetching {sitemap_url}: {e}")
60
+ continue
61
+ except Exception as e:
62
+ logger.warning(f"Unexpected error parsing {sitemap_url}: {e}")
63
+ continue
64
+
65
+ result = list(urls)
66
+ if max_urls:
67
+ result = result[:max_urls]
68
+
69
+ return result
70
+
71
+
72
+ def _get_sitemap_urls(domain: str) -> List[str]:
73
+ """
74
+ Generate possible sitemap URLs for a domain.
75
+
76
+ Args:
77
+ domain: Domain name
78
+
79
+ Returns:
80
+ List of potential sitemap URLs to try
81
+ """
82
+ # Remove any protocol if present
83
+ domain = domain.replace('http://', '').replace('https://', '').rstrip('/')
84
+
85
+ # Try both with and without www
86
+ domains_to_try = [domain]
87
+ if not domain.startswith('www.'):
88
+ domains_to_try.append(f'www.{domain}')
89
+ else:
90
+ domains_to_try.append(domain.replace('www.', '', 1))
91
+
92
+ sitemap_urls = []
93
+ for d in domains_to_try:
94
+ # Try HTTPS first, then HTTP
95
+ sitemap_urls.extend([
96
+ f'https://{d}/sitemap.xml',
97
+ f'https://{d}/sitemap_index.xml',
98
+ f'https://{d}/sitemap',
99
+ f'http://{d}/sitemap.xml',
100
+ ])
101
+
102
+ return sitemap_urls
103
+
104
+
105
+ def _parse_sitemap(
106
+ content: bytes,
107
+ max_urls: Optional[int] = None,
108
+ timeout: int = 10,
109
+ max_depth: int = 2,
110
+ current_depth: int = 0
111
+ ) -> Set[str]:
112
+ """
113
+ Parse sitemap XML content and extract URLs.
114
+
115
+ Handles both regular sitemaps and sitemap index files.
116
+
117
+ Args:
118
+ content: XML content as bytes
119
+ max_urls: Maximum URLs to extract
120
+ timeout: Request timeout for nested sitemaps
121
+ max_depth: Maximum recursion depth for sitemap indexes
122
+ current_depth: Current recursion depth
123
+
124
+ Returns:
125
+ Set of URLs found in the sitemap
126
+ """
127
+ urls: Set[str] = set()
128
+
129
+ try:
130
+ root = ET.fromstring(content)
131
+
132
+ # Define XML namespaces
133
+ namespaces = {
134
+ 'sm': 'http://www.sitemaps.org/schemas/sitemap/0.9',
135
+ 'image': 'http://www.google.com/schemas/sitemap-image/1.1',
136
+ 'news': 'http://www.google.com/schemas/sitemap-news/0.9'
137
+ }
138
+
139
+ # Check if this is a sitemap index (contains references to other sitemaps)
140
+ sitemap_refs = root.findall('.//sm:sitemap/sm:loc', namespaces)
141
+
142
+ if sitemap_refs and current_depth < max_depth:
143
+ # This is a sitemap index - fetch referenced sitemaps
144
+ logger.info(f"Found sitemap index with {len(sitemap_refs)} sitemaps")
145
+
146
+ for sitemap_loc in sitemap_refs:
147
+ if max_urls and len(urls) >= max_urls:
148
+ break
149
+
150
+ sitemap_url = sitemap_loc.text
151
+ if sitemap_url:
152
+ try:
153
+ response = requests.get(sitemap_url, timeout=timeout, headers={
154
+ 'User-Agent': 'Mozilla/5.0 (compatible; URLCollector/1.0)'
155
+ })
156
+ if response.status_code == 200:
157
+ nested_urls = _parse_sitemap(
158
+ response.content,
159
+ max_urls - len(urls) if max_urls else None,
160
+ timeout,
161
+ max_depth,
162
+ current_depth + 1
163
+ )
164
+ urls.update(nested_urls)
165
+ time.sleep(0.1) # Small delay to be polite
166
+ except Exception as e:
167
+ logger.debug(f"Error fetching nested sitemap {sitemap_url}: {e}")
168
+ continue
169
+
170
+ # Extract regular URL entries
171
+ url_entries = root.findall('.//sm:url/sm:loc', namespaces)
172
+
173
+ for loc in url_entries:
174
+ if max_urls and len(urls) >= max_urls:
175
+ break
176
+ if loc.text:
177
+ urls.add(loc.text)
178
+
179
+ except ET.ParseError as e:
180
+ logger.warning(f"XML parse error: {e}")
181
+ except Exception as e:
182
+ logger.warning(f"Error parsing sitemap: {e}")
183
+
184
+ return urls
185
+
186
+
187
+ def extract_urls_from_domains(
188
+ domains: List[str],
189
+ max_urls_per_domain: int = 10,
190
+ timeout: int = 10,
191
+ delay_between_domains: float = 0.5
192
+ ) -> dict:
193
+ """
194
+ Extract URLs from multiple domains using their sitemaps.
195
+
196
+ Args:
197
+ domains: List of domain names
198
+ max_urls_per_domain: Maximum URLs to extract per domain
199
+ timeout: Request timeout in seconds
200
+ delay_between_domains: Delay in seconds between domain requests
201
+
202
+ Returns:
203
+ Dictionary mapping domain to list of extracted URLs
204
+ """
205
+ results = {}
206
+
207
+ for i, domain in enumerate(domains):
208
+ logger.info(f"Processing domain {i+1}/{len(domains)}: {domain}")
209
+
210
+ urls = get_urls_from_sitemap(
211
+ domain,
212
+ max_urls=max_urls_per_domain,
213
+ timeout=timeout
214
+ )
215
+
216
+ results[domain] = urls
217
+
218
+ if i < len(domains) - 1: # Don't sleep after the last domain
219
+ time.sleep(delay_between_domains)
220
+
221
+ return results
tests/test_sitemap_parser.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for sitemap parser module."""
2
+
3
+ import pytest
4
+ import responses
5
+ from phising_detection.data.sitemap_parser import (
6
+ get_urls_from_sitemap,
7
+ _get_sitemap_urls,
8
+ _parse_sitemap,
9
+ extract_urls_from_domains
10
+ )
11
+
12
+
13
+ class TestGetSitemapUrls:
14
+ """Tests for _get_sitemap_urls function."""
15
+
16
+ def test_basic_domain(self):
17
+ """Test sitemap URL generation for basic domain."""
18
+ urls = _get_sitemap_urls("example.com")
19
+
20
+ assert "https://example.com/sitemap.xml" in urls
21
+ assert "https://example.com/sitemap_index.xml" in urls
22
+ assert "https://example.com/sitemap" in urls
23
+ assert "http://example.com/sitemap.xml" in urls
24
+
25
+ def test_domain_with_www(self):
26
+ """Test sitemap URL generation for domain with www."""
27
+ urls = _get_sitemap_urls("www.example.com")
28
+
29
+ # Should try both with and without www
30
+ assert any("www.example.com" in url for url in urls)
31
+ assert any("example.com/sitemap" in url and "www" not in url for url in urls)
32
+
33
+ def test_domain_without_www(self):
34
+ """Test sitemap URL generation adds www variant."""
35
+ urls = _get_sitemap_urls("example.com")
36
+
37
+ assert any("www.example.com" in url for url in urls)
38
+
39
+
40
+ class TestParseSitemap:
41
+ """Tests for _parse_sitemap function."""
42
+
43
+ def test_parse_simple_sitemap(self):
44
+ """Test parsing a simple sitemap with URLs."""
45
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
46
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
47
+ <url>
48
+ <loc>https://example.com/page1</loc>
49
+ </url>
50
+ <url>
51
+ <loc>https://example.com/page2</loc>
52
+ </url>
53
+ <url>
54
+ <loc>https://example.com/page3</loc>
55
+ </url>
56
+ </urlset>"""
57
+
58
+ urls = _parse_sitemap(sitemap_xml)
59
+
60
+ assert len(urls) == 3
61
+ assert "https://example.com/page1" in urls
62
+ assert "https://example.com/page2" in urls
63
+ assert "https://example.com/page3" in urls
64
+
65
+ def test_parse_empty_sitemap(self):
66
+ """Test parsing an empty sitemap."""
67
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
68
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
69
+ </urlset>"""
70
+
71
+ urls = _parse_sitemap(sitemap_xml)
72
+ assert len(urls) == 0
73
+
74
+ def test_parse_sitemap_with_max_urls(self):
75
+ """Test parsing sitemap with max_urls limit."""
76
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
77
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
78
+ <url><loc>https://example.com/page1</loc></url>
79
+ <url><loc>https://example.com/page2</loc></url>
80
+ <url><loc>https://example.com/page3</loc></url>
81
+ <url><loc>https://example.com/page4</loc></url>
82
+ <url><loc>https://example.com/page5</loc></url>
83
+ </urlset>"""
84
+
85
+ urls = _parse_sitemap(sitemap_xml, max_urls=3)
86
+ assert len(urls) == 3
87
+
88
+ def test_parse_invalid_xml(self):
89
+ """Test parsing invalid XML returns empty set."""
90
+ invalid_xml = b"This is not XML"
91
+
92
+ urls = _parse_sitemap(invalid_xml)
93
+ assert len(urls) == 0
94
+
95
+ @responses.activate
96
+ def test_parse_sitemap_index(self):
97
+ """Test parsing a sitemap index that references other sitemaps."""
98
+ sitemap_index_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
99
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
100
+ <sitemap>
101
+ <loc>https://example.com/sitemap1.xml</loc>
102
+ </sitemap>
103
+ <sitemap>
104
+ <loc>https://example.com/sitemap2.xml</loc>
105
+ </sitemap>
106
+ </sitemapindex>"""
107
+
108
+ sitemap1_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
109
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
110
+ <url><loc>https://example.com/page1</loc></url>
111
+ <url><loc>https://example.com/page2</loc></url>
112
+ </urlset>"""
113
+
114
+ sitemap2_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
115
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
116
+ <url><loc>https://example.com/page3</loc></url>
117
+ </urlset>"""
118
+
119
+ # Mock the nested sitemap requests
120
+ responses.add(
121
+ responses.GET,
122
+ "https://example.com/sitemap1.xml",
123
+ body=sitemap1_xml,
124
+ status=200
125
+ )
126
+ responses.add(
127
+ responses.GET,
128
+ "https://example.com/sitemap2.xml",
129
+ body=sitemap2_xml,
130
+ status=200
131
+ )
132
+
133
+ urls = _parse_sitemap(sitemap_index_xml, max_depth=2)
134
+
135
+ assert len(urls) == 3
136
+ assert "https://example.com/page1" in urls
137
+ assert "https://example.com/page2" in urls
138
+ assert "https://example.com/page3" in urls
139
+
140
+
141
+ class TestGetUrlsFromSitemap:
142
+ """Tests for get_urls_from_sitemap function."""
143
+
144
+ @responses.activate
145
+ def test_successful_sitemap_fetch(self):
146
+ """Test successful sitemap fetching."""
147
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
148
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
149
+ <url><loc>https://example.com/page1</loc></url>
150
+ <url><loc>https://example.com/page2</loc></url>
151
+ </urlset>"""
152
+
153
+ # Mock the sitemap request
154
+ responses.add(
155
+ responses.GET,
156
+ "https://example.com/sitemap.xml",
157
+ body=sitemap_xml,
158
+ status=200
159
+ )
160
+
161
+ urls = get_urls_from_sitemap("example.com", timeout=5)
162
+
163
+ assert len(urls) >= 2
164
+ assert "https://example.com/page1" in urls
165
+ assert "https://example.com/page2" in urls
166
+
167
+ @responses.activate
168
+ def test_sitemap_not_found(self):
169
+ """Test handling when sitemap is not found."""
170
+ # Mock 404 responses for all sitemap URLs
171
+ responses.add(
172
+ responses.GET,
173
+ "https://example.com/sitemap.xml",
174
+ status=404
175
+ )
176
+ responses.add(
177
+ responses.GET,
178
+ "https://example.com/sitemap_index.xml",
179
+ status=404
180
+ )
181
+ responses.add(
182
+ responses.GET,
183
+ "https://example.com/sitemap",
184
+ status=404
185
+ )
186
+ responses.add(
187
+ responses.GET,
188
+ "http://example.com/sitemap.xml",
189
+ status=404
190
+ )
191
+ responses.add(
192
+ responses.GET,
193
+ "https://www.example.com/sitemap.xml",
194
+ status=404
195
+ )
196
+ responses.add(
197
+ responses.GET,
198
+ "https://www.example.com/sitemap_index.xml",
199
+ status=404
200
+ )
201
+ responses.add(
202
+ responses.GET,
203
+ "https://www.example.com/sitemap",
204
+ status=404
205
+ )
206
+ responses.add(
207
+ responses.GET,
208
+ "http://www.example.com/sitemap.xml",
209
+ status=404
210
+ )
211
+
212
+ urls = get_urls_from_sitemap("example.com", timeout=5)
213
+
214
+ assert len(urls) == 0
215
+
216
+ @responses.activate
217
+ def test_max_urls_limit(self):
218
+ """Test that max_urls limit is respected."""
219
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
220
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
221
+ <url><loc>https://example.com/page1</loc></url>
222
+ <url><loc>https://example.com/page2</loc></url>
223
+ <url><loc>https://example.com/page3</loc></url>
224
+ <url><loc>https://example.com/page4</loc></url>
225
+ <url><loc>https://example.com/page5</loc></url>
226
+ </urlset>"""
227
+
228
+ responses.add(
229
+ responses.GET,
230
+ "https://example.com/sitemap.xml",
231
+ body=sitemap_xml,
232
+ status=200
233
+ )
234
+
235
+ urls = get_urls_from_sitemap("example.com", max_urls=3, timeout=5)
236
+
237
+ assert len(urls) <= 3
238
+
239
+ @responses.activate
240
+ def test_timeout_handling(self):
241
+ """Test that timeout is handled gracefully."""
242
+ from requests.exceptions import Timeout
243
+
244
+ # Mock timeout
245
+ responses.add(
246
+ responses.GET,
247
+ "https://example.com/sitemap.xml",
248
+ body=Timeout()
249
+ )
250
+ responses.add(
251
+ responses.GET,
252
+ "https://example.com/sitemap_index.xml",
253
+ body=Timeout()
254
+ )
255
+ responses.add(
256
+ responses.GET,
257
+ "https://example.com/sitemap",
258
+ body=Timeout()
259
+ )
260
+ responses.add(
261
+ responses.GET,
262
+ "http://example.com/sitemap.xml",
263
+ body=Timeout()
264
+ )
265
+ responses.add(
266
+ responses.GET,
267
+ "https://www.example.com/sitemap.xml",
268
+ body=Timeout()
269
+ )
270
+ responses.add(
271
+ responses.GET,
272
+ "https://www.example.com/sitemap_index.xml",
273
+ body=Timeout()
274
+ )
275
+ responses.add(
276
+ responses.GET,
277
+ "https://www.example.com/sitemap",
278
+ body=Timeout()
279
+ )
280
+ responses.add(
281
+ responses.GET,
282
+ "http://www.example.com/sitemap.xml",
283
+ body=Timeout()
284
+ )
285
+
286
+ # Should not raise exception, just return empty list
287
+ urls = get_urls_from_sitemap("example.com", timeout=1)
288
+ assert len(urls) == 0
289
+
290
+
291
+ class TestExtractUrlsFromDomains:
292
+ """Tests for extract_urls_from_domains function."""
293
+
294
+ @responses.activate
295
+ def test_extract_from_multiple_domains(self):
296
+ """Test extracting URLs from multiple domains."""
297
+ sitemap1_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
298
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
299
+ <url><loc>https://example1.com/page1</loc></url>
300
+ <url><loc>https://example1.com/page2</loc></url>
301
+ </urlset>"""
302
+
303
+ sitemap2_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
304
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
305
+ <url><loc>https://example2.com/pageA</loc></url>
306
+ </urlset>"""
307
+
308
+ # Mock sitemaps for both domains
309
+ responses.add(
310
+ responses.GET,
311
+ "https://example1.com/sitemap.xml",
312
+ body=sitemap1_xml,
313
+ status=200
314
+ )
315
+ responses.add(
316
+ responses.GET,
317
+ "https://example2.com/sitemap.xml",
318
+ body=sitemap2_xml,
319
+ status=200
320
+ )
321
+
322
+ domains = ["example1.com", "example2.com"]
323
+ results = extract_urls_from_domains(
324
+ domains,
325
+ max_urls_per_domain=10,
326
+ timeout=5,
327
+ delay_between_domains=0
328
+ )
329
+
330
+ assert "example1.com" in results
331
+ assert "example2.com" in results
332
+ assert len(results["example1.com"]) == 2
333
+ assert len(results["example2.com"]) == 1
334
+ assert "https://example1.com/page1" in results["example1.com"]
335
+ assert "https://example2.com/pageA" in results["example2.com"]
336
+
337
+ @responses.activate
338
+ def test_extract_with_failures(self):
339
+ """Test extraction continues even if some domains fail."""
340
+ sitemap_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
341
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
342
+ <url><loc>https://example1.com/page1</loc></url>
343
+ </urlset>"""
344
+
345
+ # Mock success for first domain
346
+ responses.add(
347
+ responses.GET,
348
+ "https://example1.com/sitemap.xml",
349
+ body=sitemap_xml,
350
+ status=200
351
+ )
352
+
353
+ # Mock failures for second domain
354
+ responses.add(
355
+ responses.GET,
356
+ "https://example2.com/sitemap.xml",
357
+ status=404
358
+ )
359
+ responses.add(
360
+ responses.GET,
361
+ "https://example2.com/sitemap_index.xml",
362
+ status=404
363
+ )
364
+ responses.add(
365
+ responses.GET,
366
+ "https://example2.com/sitemap",
367
+ status=404
368
+ )
369
+ responses.add(
370
+ responses.GET,
371
+ "http://example2.com/sitemap.xml",
372
+ status=404
373
+ )
374
+ responses.add(
375
+ responses.GET,
376
+ "https://www.example2.com/sitemap.xml",
377
+ status=404
378
+ )
379
+ responses.add(
380
+ responses.GET,
381
+ "https://www.example2.com/sitemap_index.xml",
382
+ status=404
383
+ )
384
+ responses.add(
385
+ responses.GET,
386
+ "https://www.example2.com/sitemap",
387
+ status=404
388
+ )
389
+ responses.add(
390
+ responses.GET,
391
+ "http://www.example2.com/sitemap.xml",
392
+ status=404
393
+ )
394
+
395
+ domains = ["example1.com", "example2.com"]
396
+ results = extract_urls_from_domains(
397
+ domains,
398
+ max_urls_per_domain=10,
399
+ timeout=5,
400
+ delay_between_domains=0
401
+ )
402
+
403
+ # Should have results for both, but example2 should be empty
404
+ assert "example1.com" in results
405
+ assert "example2.com" in results
406
+ assert len(results["example1.com"]) == 1
407
+ assert len(results["example2.com"]) == 0