Skip to content

Commit b9c1d35

Browse files
committedMar 6, 2025·
Use CORS proxy services
1 parent 5e65329 commit b9c1d35

File tree

3 files changed

+234
-12
lines changed

3 files changed

+234
-12
lines changed
 

‎README.md

+24-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ The PyHC Documentation Hub allows searching across documentation for all PyHC pa
1414

1515
## How It Works
1616

17-
The PyHC Documentation Hub leverages the Read The Docs Server-Side Search API to query multiple project documentations simultaneously. The search is performed client-side using JavaScript, which constructs queries to the Read The Docs API and presents the results in a unified interface.
17+
The PyHC Documentation Hub leverages the Read The Docs Server-Side Search API to query multiple project documentations simultaneously. The search is performed client-side using JavaScript, which constructs queries to the Read The Docs API through a CORS proxy. The results are presented in a unified interface.
18+
19+
### CORS Handling
20+
21+
Due to CORS restrictions, browsers will block direct requests from client-side JavaScript to the Read The Docs API. To solve this, we use public CORS proxy services to add the appropriate CORS headers to the API responses.
22+
23+
The search functionality uses multiple CORS proxy services (in order of preference):
24+
1. corsproxy.io
25+
2. cors-anywhere.herokuapp.com
26+
3. allorigins.win
27+
28+
If one proxy fails, the application automatically tries the next one. This provides redundancy and ensures the search functionality works consistently across different browsers and environments.
29+
30+
The implementation can be found in `source/_static/pyhc_search.js`.
1831

1932
## Development
2033

@@ -32,6 +45,16 @@ The PyHC Documentation Hub leverages the Read The Docs Server-Side Search API to
3245
3. Build the documentation: `make html`
3346
4. Open `build/html/index.html` in your browser
3447

48+
For development, you can use Python's built-in HTTP server:
49+
50+
```bash
51+
# Build and serve the documentation
52+
make html
53+
python -m http.server -d build/html
54+
```
55+
56+
Then access the documentation at http://localhost:8000
57+
3558
## License
3659

3760
This project is licensed under the terms of the LICENSE file included in this repository.

‎requirements.txt

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
sphinx==7.2.6
2+
sphinx-rtd-theme==2.0.0
3+
sphinx-copybutton==0.5.2

‎source/_static/pyhc_search.js

+207-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// PyHC Documentation Hub - Unified Search
2+
// This script uses public CORS proxy services to make cross-origin requests to the Read the Docs API.
3+
// Multiple proxy services are tried in sequence for redundancy.
24
document.addEventListener('DOMContentLoaded', function() {
35
// Add scroll event handler to toggle shadow on tabs when scrolling
46
function initScrollShadow() {
@@ -28,8 +30,29 @@ document.addEventListener('DOMContentLoaded', function() {
2830
const rtdSearchForm = document.getElementById('rtd-search-form');
2931
const rtdSearchInput = rtdSearchForm ? rtdSearchForm.querySelector('input[type="text"]') : null;
3032

31-
// Base API URL
32-
const apiBaseUrl = 'https://readthedocs.org/api/v3/search/';
33+
// Base API URL with public CORS proxy services
34+
const rtdApiUrl = 'https://readthedocs.org/api/v3/search/';
35+
36+
// List of CORS proxies to try (in order of preference)
37+
const corsProxies = [
38+
'https://corsproxy.io/?',
39+
'https://cors-anywhere.herokuapp.com/',
40+
'https://api.allorigins.win/raw?url='
41+
];
42+
43+
// Start with the first proxy
44+
let currentProxyIndex = 0;
45+
const corsProxyUrl = corsProxies[currentProxyIndex];
46+
let apiBaseUrl = corsProxyUrl + encodeURIComponent(rtdApiUrl);
47+
48+
// Function to switch to the next proxy if one fails
49+
function switchToNextProxy() {
50+
currentProxyIndex = (currentProxyIndex + 1) % corsProxies.length;
51+
const nextProxy = corsProxies[currentProxyIndex];
52+
apiBaseUrl = nextProxy + encodeURIComponent(rtdApiUrl);
53+
console.log(`Switched to CORS proxy: ${nextProxy}`);
54+
return apiBaseUrl;
55+
}
3356

3457
// Modal elements (will be defined after createSearchModal)
3558
const searchModal = document.getElementById('pyhc-search-modal');
@@ -446,8 +469,11 @@ document.addEventListener('DOMContentLoaded', function() {
446469
// Build the full search query with space between project list and search term
447470
const fullQuery = `${projectsQuery} ${query}`;
448471

449-
// Build the URL with the correctly encoded query parameter
450-
const queryUrl = `${apiBaseUrl}?q=${encodeURIComponent(fullQuery)}`;
472+
// Build the query parameter
473+
const queryParam = `q=${encodeURIComponent(fullQuery)}`;
474+
475+
// When using corsproxy.io, we need to add the query parameter to the encoded URL
476+
const queryUrl = `${apiBaseUrl}${encodeURIComponent('?' + queryParam)}`;
451477

452478
// Fetch results
453479
fetch(queryUrl)
@@ -463,9 +489,43 @@ document.addEventListener('DOMContentLoaded', function() {
463489
displayResults(data, query);
464490
})
465491
.catch(error => {
466-
// Reset search icon to magnifying glass
467-
resetSearchIcon();
468-
showMessage(`An error occurred while searching: ${error.message}. Please try again later.`);
492+
console.error("Search error:", error);
493+
494+
// Check if we've tried all proxies
495+
if (corsProxies.length > 1) {
496+
// Try with next proxy
497+
switchToNextProxy();
498+
499+
// Update the query URL with the new proxy
500+
const newQueryUrl = `${apiBaseUrl}${encodeURIComponent('?' + queryParam)}`;
501+
502+
// Show a trying alternative proxy message
503+
showMessage(`Trying alternative proxy service... Please wait.`);
504+
505+
// Retry the search with the new proxy
506+
setTimeout(() => {
507+
fetch(newQueryUrl)
508+
.then(response => {
509+
if (!response.ok) {
510+
throw new Error(`Network response was not ok: ${response.status} ${response.statusText}`);
511+
}
512+
return response.json();
513+
})
514+
.then(data => {
515+
resetSearchIcon();
516+
displayResults(data, query);
517+
})
518+
.catch(retryError => {
519+
// If all proxies fail, show error message
520+
resetSearchIcon();
521+
showMessage(`Search failed. The search service may be temporarily unavailable. Please try again later.`);
522+
});
523+
}, 1000);
524+
} else {
525+
// Reset search icon to magnifying glass
526+
resetSearchIcon();
527+
showMessage(`An error occurred while searching: ${error.message}. Please try again later.`);
528+
}
469529
});
470530
}
471531

@@ -869,7 +929,10 @@ document.addEventListener('DOMContentLoaded', function() {
869929
loadMoreBtn.disabled = true;
870930
}
871931

872-
fetch(nextUrl)
932+
// We need to handle the next URL through the CORS proxy
933+
const proxiedNextUrl = corsProxyUrl + encodeURIComponent(nextUrl);
934+
935+
fetch(proxiedNextUrl)
873936
.then(response => {
874937
if (!response.ok) {
875938
throw new Error('Network response was not ok');
@@ -981,9 +1044,142 @@ document.addEventListener('DOMContentLoaded', function() {
9811044
})
9821045
.catch(error => {
9831046
console.error('Error loading more results:', error);
984-
if (loadMoreBtn) {
985-
loadMoreBtn.textContent = 'Error loading more results. Click to try again.';
986-
loadMoreBtn.disabled = false;
1047+
1048+
// Try with the next proxy if available
1049+
if (corsProxies.length > 1) {
1050+
switchToNextProxy();
1051+
const retryProxiedUrl = corsProxyUrl + encodeURIComponent(nextUrl);
1052+
1053+
if (loadMoreBtn) {
1054+
loadMoreBtn.textContent = 'Trying alternative proxy service...';
1055+
}
1056+
1057+
// Retry with new proxy
1058+
setTimeout(() => {
1059+
fetch(retryProxiedUrl)
1060+
.then(response => {
1061+
if (!response.ok) {
1062+
throw new Error('Network response was not ok');
1063+
}
1064+
return response.json();
1065+
})
1066+
.then(data => {
1067+
// Remove the load more button
1068+
if (loadMoreBtn) {
1069+
loadMoreBtn.remove();
1070+
}
1071+
1072+
// Continue with processing the data as before
1073+
// Group new results by project
1074+
const resultsByProject = {};
1075+
1076+
data.results.forEach(result => {
1077+
const projectSlug = result.project.slug;
1078+
if (!resultsByProject[projectSlug]) {
1079+
resultsByProject[projectSlug] = [];
1080+
}
1081+
resultsByProject[projectSlug].push(result);
1082+
});
1083+
1084+
// Get existing project tabs container
1085+
const tabsContainer = resultsContainer.querySelector('.project-tabs');
1086+
1087+
// Process each project's results
1088+
for (const [project, results] of Object.entries(resultsByProject)) {
1089+
// Check if there's already a container for this project
1090+
let projectContainer = resultsContainer.querySelector(`.project-results[data-project="${project}"]`);
1091+
1092+
// If there's no tab for this project yet, create it
1093+
if (!projectContainer) {
1094+
// Create a new tab for this project
1095+
const tab = document.createElement('div');
1096+
tab.className = 'project-tab';
1097+
tab.dataset.project = project;
1098+
tab.innerHTML = `
1099+
<div class="project-tab-icon">
1100+
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="book" class="svg-inline--fa fa-book" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
1101+
<path fill="currentColor" d="M96 0C43 0 0 43 0 96V416c0 53 43 96 96 96H384h32c17.7 0 32-14.3 32-32s-14.3-32-32-32V384c17.7 0 32-14.3 32-32V32c0-17.7-14.3-32-32-32H384 96zm0 384H352v64H96c-17.7 0-32-14.3-32-32s14.3-32 32-32zm32-240c0-8.8 7.2-16 16-16H336c8.8 0 16 7.2 16 16s-7.2 16-16 16H144c-8.8 0-16-7.2-16-16zm16 48H336c8.8 0 16 7.2 16 16s-7.2 16-16 16H144c-8.8 0-16-7.2-16-16s7.2-16 16-16z"></path>
1102+
</svg>
1103+
</div>
1104+
${project} (${results.length})
1105+
`;
1106+
1107+
// Add tab click handler
1108+
tab.addEventListener('click', function() {
1109+
// Remove active class from all tabs and containers
1110+
document.querySelectorAll('.project-tab').forEach(t => t.classList.remove('active'));
1111+
document.querySelectorAll('.project-results').forEach(c => c.classList.remove('active'));
1112+
1113+
// Add active class to this tab and its container
1114+
tab.classList.add('active');
1115+
const container = resultsContainer.querySelector(`.project-results[data-project="${project}"]`);
1116+
if (container) container.classList.add('active');
1117+
});
1118+
1119+
tabsContainer.appendChild(tab);
1120+
1121+
// Create a new container for this project
1122+
projectContainer = document.createElement('div');
1123+
projectContainer.className = 'project-results';
1124+
projectContainer.dataset.project = project;
1125+
1126+
// Find the load more button to insert before it (if it exists)
1127+
const loadMoreBtn = resultsContainer.querySelector('.pyhc-load-more');
1128+
if (loadMoreBtn) {
1129+
resultsContainer.insertBefore(projectContainer, loadMoreBtn);
1130+
} else {
1131+
resultsContainer.appendChild(projectContainer);
1132+
}
1133+
} else {
1134+
// If this project already exists, update the count in the tab
1135+
const projectTab = tabsContainer.querySelector(`.project-tab[data-project="${project}"]`);
1136+
if (projectTab) {
1137+
// Get current count from tab
1138+
const currentCount = parseInt(projectTab.innerText.match(/\((\d+)\)/)[1]);
1139+
// Update tab text with new count
1140+
const newCount = currentCount + results.length;
1141+
projectTab.innerHTML = projectTab.innerHTML.replace(/\(\d+\)/, `(${newCount})`);
1142+
}
1143+
}
1144+
1145+
// Add each result for this project to its container
1146+
results.forEach(result => {
1147+
const resultItem = createResultItem(result);
1148+
projectContainer.appendChild(resultItem);
1149+
});
1150+
}
1151+
1152+
// Add pagination if needed
1153+
if (data.next) {
1154+
// Calculate remaining pages
1155+
const currentResultsCount = Array.from(document.querySelectorAll('.project-results .hit-block')).length;
1156+
const totalResultsCount = data.count;
1157+
const remainingResults = totalResultsCount - currentResultsCount;
1158+
const remainingPages = Math.ceil(remainingResults / 50);
1159+
1160+
const newLoadMoreBtn = document.createElement('button');
1161+
newLoadMoreBtn.className = 'pyhc-load-more';
1162+
newLoadMoreBtn.textContent = `Load more results (${remainingPages} ${remainingPages === 1 ? 'page' : 'pages'} remaining)`;
1163+
newLoadMoreBtn.addEventListener('click', function() {
1164+
loadNextPage(data.next);
1165+
});
1166+
resultsContainer.appendChild(newLoadMoreBtn);
1167+
}
1168+
})
1169+
.catch(retryError => {
1170+
// If retry fails, show error
1171+
if (loadMoreBtn) {
1172+
loadMoreBtn.textContent = 'Error loading more results. Click to try again.';
1173+
loadMoreBtn.disabled = false;
1174+
}
1175+
});
1176+
}, 1000);
1177+
} else {
1178+
// If no more proxies to try
1179+
if (loadMoreBtn) {
1180+
loadMoreBtn.textContent = 'Error loading more results. Click to try again.';
1181+
loadMoreBtn.disabled = false;
1182+
}
9871183
}
9881184
});
9891185
}

0 commit comments

Comments
 (0)
Please sign in to comment.