Spaces:
Paused
Paused
File size: 55,398 Bytes
700aac1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 | <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SaveNest API β Developer Documentation</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Unbounded:wght@300;400;600;700&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0f0a;
--surface: #111811;
--surface2: #162016;
--surface3: #1a251a;
--border: #1e2e1e;
--border2: #162016;
--green: #2dff6e;
--green-dim: #1a9940;
--green-glow: rgba(45,255,110,0.10);
--green-faint: rgba(45,255,110,0.04);
--amber: #ffb347;
--red: #ff5f5f;
--teal: #38d9c0;
--text: #e8f5e8;
--text2: #9ab89a;
--muted: #6b8f6b;
--muted2: #3d5c3d;
--code: #080d08;
--sidebar-w: 272px;
}
* { margin:0; padding:0; box-sizing:border-box; }
html { scroll-behavior:smooth; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
min-height: 100vh;
display: flex;
overflow-x: hidden;
}
/* Grid bg */
body::before {
content:'';
position:fixed; inset:0;
background-image:
linear-gradient(rgba(45,255,110,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(45,255,110,0.025) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events:none; z-index:0;
}
/* ββ SIDEBAR ββ */
.sidebar {
width: var(--sidebar-w);
min-height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
position: fixed;
top:0; left:0;
overflow-y: auto;
z-index: 100;
display: flex;
flex-direction: column;
}
.sidebar::-webkit-scrollbar { width:3px; }
.sidebar::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
.sidebar-logo {
padding: 26px 22px 18px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px;
}
.logo-icon {
width: 38px; height: 38px;
background: var(--green);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px; flex-shrink:0;
box-shadow: 0 0 16px rgba(45,255,110,0.35);
}
.logo-text-wrap {}
.logo-name {
font-family: 'Unbounded', sans-serif;
font-size: 0.88rem; font-weight: 700;
color: var(--text); letter-spacing: -0.01em;
}
.logo-name span { color: var(--green); }
.logo-sub {
font-family: 'Space Mono', monospace;
font-size: 0.58rem; color: var(--muted);
letter-spacing: 0.1em; text-transform: uppercase;
margin-top: 2px;
}
.sidebar-search {
padding: 14px 14px 10px;
border-bottom: 1px solid var(--border);
}
.search-wrap { position:relative; }
.search-wrap::before {
content:'β'; position:absolute; left:10px; top:50%;
transform:translateY(-50%); color:var(--muted2); font-size:1rem;
pointer-events:none;
}
.search-input {
width:100%; background:var(--bg); border:1px solid var(--border);
border-radius:7px; padding:8px 10px 8px 32px;
color:var(--text); font-family:'Space Mono',monospace; font-size:0.72rem;
outline:none; transition:border-color .2s;
}
.search-input:focus { border-color:var(--green-dim); }
.search-input::placeholder { color:var(--muted2); }
.nav-group { padding: 16px 12px 4px; }
.nav-label {
font-family:'Space Mono',monospace; font-size:0.58rem;
text-transform:uppercase; letter-spacing:0.18em;
color:var(--muted2); padding:0 8px; margin-bottom:5px;
}
.nav-item {
display:flex; align-items:center; gap:8px;
padding:7px 9px; border-radius:7px;
cursor:pointer; font-size:0.84rem; color:var(--text2);
transition:all .15s; text-decoration:none;
border:1px solid transparent; margin-bottom:2px;
position:relative;
}
.nav-item:hover { background:var(--surface2); color:var(--text); }
.nav-item.active {
background:var(--green-glow);
border-color:rgba(45,255,110,0.2);
color:var(--green);
}
.nav-item.active::before {
content:''; position:absolute; left:-1px; top:22%; bottom:22%;
width:3px; background:var(--green); border-radius:0 2px 2px 0;
}
.nav-icon { font-size:0.85rem; opacity:.7; flex-shrink:0; }
.nav-badge {
margin-left:auto; font-family:'Space Mono',monospace; font-size:0.56rem;
padding:2px 6px; border-radius:8px;
background:var(--green-glow); color:var(--green);
border:1px solid rgba(45,255,110,0.2);
}
.nav-badge.get { background:rgba(56,217,192,.1); color:var(--teal); border-color:rgba(56,217,192,.2); }
.sidebar-footer {
margin-top:auto; padding:16px 14px;
border-top:1px solid var(--border);
}
.ver {
font-family:'Space Mono',monospace; font-size:0.62rem; color:var(--muted);
display:flex; align-items:center; gap:6px;
}
.live-dot {
width:6px; height:6px; background:var(--green); border-radius:50%;
box-shadow:0 0 6px var(--green); animation:pulse 2s infinite;
}
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.4} }
/* ββ MAIN ββ */
.main { margin-left:var(--sidebar-w); flex:1; min-height:100vh; position:relative; z-index:1; }
.topbar {
position:sticky; top:0;
background:rgba(10,15,10,0.9); backdrop-filter:blur(14px);
border-bottom:1px solid var(--border);
padding:13px 44px; display:flex; align-items:center; gap:14px; z-index:50;
}
.breadcrumb {
font-family:'Space Mono',monospace; font-size:0.7rem; color:var(--muted);
display:flex; align-items:center; gap:7px;
}
.breadcrumb span { color:var(--text2); }
.topbar-right { margin-left:auto; display:flex; gap:9px; align-items:center; }
.tbtn {
font-family:'Space Mono',monospace; font-size:0.66rem;
padding:5px 13px; border-radius:7px;
border:1px solid var(--border); background:transparent;
color:var(--text2); cursor:pointer; transition:all .15s;
text-decoration:none; display:flex; align-items:center; gap:5px;
}
.tbtn:hover { border-color:var(--green-dim); color:var(--green); }
.tbtn.back { border-color:rgba(45,255,110,0.25); color:var(--green); }
.tbtn.back:hover { background:var(--green-glow); }
.tbtn.primary { background:var(--green); color:var(--bg); border-color:var(--green); font-weight:700; }
.tbtn.primary:hover { background:#50ff88; }
.content { max-width:840px; margin:0 auto; padding:52px 44px 100px; }
/* ββ SECTIONS ββ */
.section { display:none; animation:fadeUp .3s ease; }
.section.active { display:block; }
@keyframes fadeUp { from{opacity:0;transform:translateY(10px)} to{opacity:1;transform:none} }
/* ββ HERO ββ */
.eyebrow {
font-family:'Space Mono',monospace; font-size:0.66rem;
text-transform:uppercase; letter-spacing:0.2em; color:var(--green);
margin-bottom:18px; display:flex; align-items:center; gap:10px;
}
.eyebrow::before { content:''; width:28px; height:1px; background:var(--green); }
.page-title {
font-family:'Unbounded',sans-serif;
font-size:clamp(2rem,4vw,3rem); font-weight:700;
line-height:1.15; letter-spacing:-0.03em; margin-bottom:18px;
}
.page-title em { font-style:normal; color:var(--green); }
.page-desc { font-size:1rem; color:var(--text2); line-height:1.8; max-width:600px; margin-bottom:32px; }
.stats-grid {
display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:44px;
}
.stat-card {
background:var(--surface); border:1px solid var(--border);
border-radius:11px; padding:16px 18px; transition:border-color .2s;
}
.stat-card:hover { border-color:var(--green-dim); }
.stat-n {
font-family:'Unbounded',sans-serif; font-size:1.7rem; font-weight:700;
color:var(--green); line-height:1; margin-bottom:4px;
}
.stat-l { font-size:0.75rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.08em; }
/* ββ TYPOGRAPHY ββ */
h2 {
font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:600;
letter-spacing:-0.02em; color:var(--text);
margin:48px 0 14px; padding-bottom:12px;
border-bottom:1px solid var(--border);
display:flex; align-items:center; gap:10px;
}
h3 {
font-family:'DM Sans',sans-serif; font-size:1rem; font-weight:600;
color:var(--text); margin:24px 0 10px;
}
p { font-size:0.93rem; color:var(--text2); line-height:1.8; margin-bottom:14px; }
/* ββ CALLOUTS ββ */
.callout {
border-radius:10px; padding:16px 20px; margin:20px 0;
display:flex; gap:12px; font-size:0.88rem; line-height:1.7;
}
.callout-icon { font-size:1.1rem; flex-shrink:0; margin-top:1px; }
.callout.info { background:rgba(45,255,110,.06); border:1px solid rgba(45,255,110,.2); color:var(--text2); }
.callout.warn { background:rgba(255,179,71,.07); border:1px solid rgba(255,179,71,.25); color:var(--text2); }
.callout.danger { background:rgba(255,95,95,.07); border:1px solid rgba(255,95,95,.25); color:var(--text2); }
.callout strong { color:var(--text); }
/* ββ CODE ββ */
.code-wrap { position:relative; margin:18px 0; }
.code-lang {
font-family:'Space Mono',monospace; font-size:0.6rem;
text-transform:uppercase; letter-spacing:0.12em; color:var(--muted);
background:var(--surface2); border:1px solid var(--border); border-bottom:none;
padding:5px 13px; border-radius:7px 7px 0 0; display:inline-block;
}
pre {
background:var(--code); border:1px solid var(--border);
border-radius:0 7px 7px 7px; padding:18px 22px;
overflow-x:auto; font-family:'Space Mono',monospace;
font-size:0.79rem; line-height:1.75; color:#7ec47e;
}
pre::-webkit-scrollbar { height:3px; }
pre::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
.copy-btn {
position:absolute; top:34px; right:10px;
font-family:'Space Mono',monospace; font-size:0.6rem;
padding:3px 9px; border:1px solid var(--border);
border-radius:5px; background:var(--surface2); color:var(--muted);
cursor:pointer; transition:all .15s;
}
.copy-btn:hover { border-color:var(--green-dim); color:var(--green); }
code {
font-family:'Space Mono',monospace; font-size:0.82em;
background:var(--surface2); border:1px solid var(--border);
border-radius:4px; padding:2px 6px; color:var(--green);
}
/* ββ ENDPOINT CARDS ββ */
.ep {
background:var(--surface); border:1px solid var(--border);
border-radius:12px; margin:16px 0; overflow:hidden; transition:border-color .2s;
}
.ep:hover { border-color:var(--green-dim); }
.ep-head {
padding:16px 22px; display:flex; align-items:center; gap:12px; cursor:pointer;
}
.method {
font-family:'Space Mono',monospace; font-size:0.66rem; font-weight:700;
padding:3px 10px; border-radius:5px; letter-spacing:.05em; flex-shrink:0;
}
.method.GET { background:rgba(56,217,192,.1); color:var(--teal); border:1px solid rgba(56,217,192,.25); }
.method.POST { background:var(--green-glow); color:var(--green); border:1px solid rgba(45,255,110,.25); }
.ep-path { font-family:'Space Mono',monospace; font-size:0.9rem; color:var(--text); }
.ep-desc { font-size:0.8rem; color:var(--muted); margin-left:auto; }
.chevron { color:var(--muted2); transition:transform .2s; font-size:0.78rem; }
.ep.open .chevron { transform:rotate(180deg); }
.ep-body { display:none; padding:0 22px 22px; border-top:1px solid var(--border); }
.ep.open .ep-body { display:block; }
/* ββ PARAM TABLE ββ */
.ptable { width:100%; border-collapse:collapse; margin:16px 0; font-size:.83rem; }
.ptable th {
font-family:'Space Mono',monospace; font-size:.6rem;
text-transform:uppercase; letter-spacing:.14em; color:var(--muted2);
text-align:left; padding:7px 11px; border-bottom:1px solid var(--border);
}
.ptable td { padding:10px 11px; border-bottom:1px solid rgba(30,46,30,.5); vertical-align:top; }
.ptable tr:last-child td { border-bottom:none; }
.pn { font-family:'Space Mono',monospace; color:var(--teal); font-size:.8rem; }
.pt { font-family:'Space Mono',monospace; color:var(--amber); font-size:.76rem; }
.req { font-size:.58rem; padding:2px 5px; border-radius:3px; background:rgba(255,95,95,.12); color:var(--red); border:1px solid rgba(255,95,95,.25); margin-left:4px; }
.opt { font-size:.58rem; padding:2px 5px; border-radius:3px; background:rgba(107,143,107,.12); color:var(--muted); border:1px solid var(--border); margin-left:4px; }
.pd { color:var(--text2); font-size:.83rem; }
/* ββ RESPONSE FIELDS ββ */
.rf { display:flex; gap:12px; padding:9px 0; border-bottom:1px solid var(--border); font-size:.83rem; }
.rf:last-child { border-bottom:none; }
.rn { font-family:'Space Mono',monospace; color:var(--teal); min-width:150px; flex-shrink:0; }
.rt { font-family:'Space Mono',monospace; color:var(--amber); min-width:75px; flex-shrink:0; font-size:.76rem; }
.rd { color:var(--text2); }
/* ββ STORE GRID ββ */
.store-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:10px; margin:18px 0; }
.store-card {
background:var(--surface); border:1px solid var(--border);
border-radius:10px; padding:14px 16px;
display:flex; align-items:center; gap:10px; transition:border-color .2s;
}
.store-card:hover { border-color:var(--green-dim); }
.sdot { width:7px; height:7px; background:var(--green); border-radius:50%; box-shadow:0 0 5px var(--green); flex-shrink:0; }
.sname { font-size:.88rem; color:var(--text); }
.skey { font-family:'Space Mono',monospace; font-size:.66rem; color:var(--muted); margin-left:auto; }
/* ββ STEPS ββ */
.steps { counter-reset:step; margin:18px 0; }
.step { display:flex; gap:14px; padding:16px 0; border-bottom:1px solid var(--border); }
.step:last-child { border-bottom:none; }
.step-n {
font-family:'Unbounded',sans-serif; font-size:1.3rem; font-weight:700;
color:var(--green); opacity:.35; line-height:1; flex-shrink:0; min-width:24px;
}
.step-c h4 { font-size:.9rem; font-weight:600; color:var(--text); margin-bottom:5px; }
.step-c p { font-size:.83rem; margin:0; }
/* ββ FEATURE LIST ββ */
.flist { list-style:none; margin:14px 0; }
.flist li {
display:flex; align-items:flex-start; gap:9px;
padding:8px 0; border-bottom:1px solid rgba(30,46,30,.4);
font-size:.88rem; color:var(--text2); line-height:1.6;
}
.flist li:last-child { border-bottom:none; }
.flist li::before { content:'βΈ'; color:var(--green); font-size:.6rem; margin-top:5px; flex-shrink:0; }
/* ββ PROPOSAL BOX ββ */
.proposal-box {
background:linear-gradient(135deg,rgba(45,255,110,.05) 0%,rgba(56,217,192,.03) 100%);
border:1px solid rgba(45,255,110,.25); border-radius:14px;
padding:28px 32px; margin:28px 0; position:relative; overflow:hidden;
}
.proposal-box::after {
content:'π·οΈ'; position:absolute; top:18px; right:22px;
font-size:1.8rem; opacity:.15;
}
.proposal-box h3 { margin-top:0; color:var(--green); }
/* ββ ERROR ROWS ββ */
.er { display:flex; align-items:flex-start; gap:14px; padding:12px 0; border-bottom:1px solid var(--border); font-size:.83rem; }
.er:last-child { border-bottom:none; }
.ec { font-family:'Space Mono',monospace; font-size:.8rem; padding:3px 9px; border-radius:5px; flex-shrink:0; min-width:48px; text-align:center; }
.ec.e400 { background:rgba(255,179,71,.12); color:var(--amber); border:1px solid rgba(255,179,71,.25); }
.ec.e422 { background:rgba(255,179,71,.12); color:var(--amber); border:1px solid rgba(255,179,71,.25); }
.ec.e500 { background:rgba(255,95,95,.12); color:var(--red); border:1px solid rgba(255,95,95,.25); }
/* ββ DIVIDER ββ */
.divider { height:1px; background:var(--border); margin:36px 0; }
/* ββ TAGS ββ */
.tags { display:flex; flex-wrap:wrap; gap:7px; margin:14px 0; }
.tag {
font-family:'Space Mono',monospace; font-size:.68rem;
padding:4px 11px; border-radius:16px;
border:1px solid var(--border); color:var(--text2); background:var(--surface);
}
/* ββ MOBILE ββ */
.mob-toggle {
display:none; position:fixed; top:13px; left:13px; z-index:200;
background:var(--surface); border:1px solid var(--border);
border-radius:7px; padding:7px 11px; cursor:pointer; font-size:.95rem;
}
@media(max-width:800px){
.sidebar { transform:translateX(-100%); transition:transform .3s; }
.sidebar.open { transform:translateX(0); }
.main { margin-left:0; }
.content { padding:36px 22px 80px; }
.topbar { padding:12px 22px; }
.stats-grid { grid-template-columns:1fr 1fr; }
.mob-toggle { display:block; }
}
</style>
</head>
<body>
<button class="mob-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">β°</button>
<!-- ββ SIDEBAR ββ -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo-icon">π</div>
<div class="logo-text-wrap">
<div class="logo-name">Save<span>Nest</span></div>
<div class="logo-sub">API Docs</div>
</div>
</div>
<div class="sidebar-search">
<div class="search-wrap">
<input class="search-input" type="text" placeholder="Search docsβ¦" oninput="searchNav(this.value)">
</div>
</div>
<nav>
<div class="nav-group">
<div class="nav-label">Getting Started</div>
<a class="nav-item active" onclick="show('overview')"><span class="nav-icon">β</span> Overview</a>
<a class="nav-item" onclick="show('quickstart')"><span class="nav-icon">β‘</span> Quick Start</a>
<a class="nav-item" onclick="show('stores')"><span class="nav-icon">πͺ</span> Supported Stores</a>
</div>
<div class="nav-group">
<div class="nav-label">API Reference</div>
<a class="nav-item" onclick="show('search')"><span class="nav-icon">π</span> /search <span class="nav-badge get">GET</span></a>
<a class="nav-item" onclick="show('crawl')"><span class="nav-icon">π·οΈ</span> /crawl <span class="nav-badge get">GET</span></a>
<a class="nav-item" onclick="show('health')"><span class="nav-icon">π</span> /health <span class="nav-badge get">GET</span></a>
</div>
<div class="nav-group">
<div class="nav-label">Concepts</div>
<a class="nav-item" onclick="show('sitemap')"><span class="nav-icon">πΊοΈ</span> Sitemap & BFS</a>
<a class="nav-item" onclick="show('parsing')"><span class="nav-icon">π¬</span> Product Parsing</a>
<a class="nav-item" onclick="show('pagination')"><span class="nav-icon">π</span> Pagination</a>
<a class="nav-item" onclick="show('playwright')"><span class="nav-icon">π</span> JS Rendering</a>
</div>
<div class="nav-group">
<div class="nav-label">Integration</div>
<a class="nav-item" onclick="show('flutter')"><span class="nav-icon">π±</span> Flutter Guide</a>
<a class="nav-item" onclick="show('errors')"><span class="nav-icon">β οΈ</span> Error Handling</a>
<a class="nav-item" onclick="show('upwork')"><span class="nav-icon">πΌ</span> Upwork Proposal</a>
</div>
</nav>
<div class="sidebar-footer">
<div class="ver"><span class="live-dot"></span> v2.0 Β· FastAPI Β· Playwright</div>
</div>
</aside>
<!-- ββ MAIN ββ -->
<main class="main">
<div class="topbar">
<div class="breadcrumb">Docs <span>βΊ</span> <span id="bc">Overview</span></div>
<div class="topbar-right">
<a class="tbtn back" href="/">β Back to App</a>
<a class="tbtn" href="/docs" target="_blank">β Swagger</a>
<a class="tbtn" href="/health" target="_blank">π Health</a>
<a class="tbtn primary" href="https://frnklnwrld-savenest-api.hf.space/" target="_blank">β Live API</a>
</div>
</div>
<div class="content">
<!-- ββ OVERVIEW ββ -->
<section id="sec-overview" class="section active">
<div class="eyebrow">Price Intelligence API</div>
<h1 class="page-title">SaveNest <em>Crawler</em></h1>
<p class="page-desc">Real-time product price scraping across Pakistan's top grocery stores. Sitemap enumeration, BFS fallback, JSON-LD parsing, and optional Playwright JS rendering β all in one FastAPI backend.</p>
<div class="stats-grid">
<div class="stat-card"><div class="stat-n">5</div><div class="stat-l">Stores</div></div>
<div class="stat-card"><div class="stat-n">500</div><div class="stat-l">Max / Call</div></div>
<div class="stat-card"><div class="stat-n">3</div><div class="stat-l">Endpoints</div></div>
<div class="stat-card"><div class="stat-n">PKR</div><div class="stat-l">Currency</div></div>
</div>
<h2>What is this API?</h2>
<p>The SaveNest Crawler API is a stateless scraping backend. It enumerates product URLs via sitemaps (Shopify / WooCommerce / generic), falls back to bounded BFS crawling when no sitemap is available, and scrapes product details using JSON-LD structured data with CSS selector fallbacks.</p>
<p>The API is designed to be consumed by Flutter apps, web dashboards, or Firestore pipelines. All state lives on the client β use <code>cursor</code> to paginate and persist results in your own database.</p>
<h2>Architecture</h2>
<ul class="flist">
<li><strong>FastAPI</strong> β async Python backend on Hugging Face Spaces (free tier)</li>
<li><strong>Sitemap discovery</strong> β robots.txt parsing + store-specific hints + default paths</li>
<li><strong>BFS fallback</strong> β bounded breadth-first crawl (max 50 pages) when no sitemap exists</li>
<li><strong>JSON-LD first</strong> β parses <code>application/ld+json</code> Product schema; CSS selectors as fallback</li>
<li><strong>Playwright (Firefox)</strong> β optional JS rendering for dynamic sites like Daraz</li>
<li><strong>In-memory TTL cache</strong> β 3-minute page cache per URL, per-request isolation for /search</li>
<li><strong>Rate limiter</strong> β per-host politeness gap (default 2s) to avoid bans</li>
<li><strong>CORS enabled</strong> β open to all origins, ready for any client</li>
</ul>
<div class="callout info">
<span class="callout-icon">βΉοΈ</span>
<div><strong>Base URL:</strong> <code>https://frnklnwrld-savenest-api.hf.space</code><br>
All endpoints return JSON. No auth required from clients.</div>
</div>
</section>
<!-- ββ QUICK START ββ -->
<section id="sec-quickstart" class="section">
<div class="eyebrow">Getting Started</div>
<h1 class="page-title">Quick <em>Start</em></h1>
<p class="page-desc">Search for prices or crawl a full store in under 2 minutes.</p>
<div class="steps">
<div class="step"><div class="step-n">1</div><div class="step-c"><h4>Search across all stores</h4><p>Hit <code>/search?q=milk</code> β the API queries all 5 stores simultaneously and returns deduplicated, price-sorted results.</p></div></div>
<div class="step"><div class="step-n">2</div><div class="step-c"><h4>Crawl a specific store</h4><p>Hit <code>/crawl?store=alfatah.pk&mode=urls</code> to enumerate all product URLs. Add <code>mode=full</code> to also scrape prices.</p></div></div>
<div class="step"><div class="step-n">3</div><div class="step-c"><h4>Paginate large stores</h4><p>Use <code>cursor=next_cursor</code> from each response to fetch the next batch. Repeat until <code>next_cursor</code> is <code>null</code>.</p></div></div>
</div>
<h3>cURL β Search</h3>
<div class="code-wrap">
<div class="code-lang">bash</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>curl "https://frnklnwrld-savenest-api.hf.space/search?q=milk&limit=10"</pre>
</div>
<h3>cURL β Crawl (URL mode)</h3>
<div class="code-wrap">
<div class="code-lang">bash</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>curl "https://frnklnwrld-savenest-api.hf.space/crawl?store=alfatah.pk&mode=urls&limit=50"</pre>
</div>
<h3>JavaScript</h3>
<div class="code-wrap">
<div class="code-lang">javascript</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>// Search across all stores
const res = await fetch(
'https://frnklnwrld-savenest-api.hf.space/search?q=olpers+milk&limit=20'
);
const data = await res.json();
data.offers.forEach(item => {
console.log(`${item.source}: ${item.title} β PKR ${item.price}`);
// Al-Fatah: Olpers Full Cream Milk 1L β PKR 285
});</pre>
</div>
<h3>Sample Search Response</h3>
<div class="code-wrap">
<div class="code-lang">json</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>{
"query": "milk",
"count": 4,
"offers": [
{
"source": "Al-Fatah",
"title": "Olpers Full Cream Milk 1L",
"price": 285.0,
"currency": "PKR",
"url": "https://alfatah.pk/products/olpers-milk-1l",
"image": "https://cdn.shopify.com/...",
"in_stock": true
},
{
"source": "Springs Store",
"title": "Nestle Milkpak UHT Milk 1L",
"price": 290.0,
"currency": "PKR",
"url": "https://springs.com.pk/product/...",
"image": null,
"in_stock": null
}
]
}</pre>
</div>
</section>
<!-- ββ STORES ββ -->
<section id="sec-stores" class="section">
<div class="eyebrow">Coverage</div>
<h1 class="page-title">Supported <em>Stores</em></h1>
<div class="store-grid">
<div class="store-card"><div class="sdot"></div><span class="sname">Al-Fatah</span><span class="skey">alfatah.pk</span></div>
<div class="store-card"><div class="sdot"></div><span class="sname">QnE</span><span class="skey">qne.com.pk</span></div>
<div class="store-card"><div class="sdot"></div><span class="sname">Springs Store</span><span class="skey">springs.com.pk</span></div>
<div class="store-card"><div class="sdot"></div><span class="sname">Vmart</span><span class="skey">vmart.pk</span></div>
<div class="store-card"><div class="sdot"></div><span class="sname">GrocerApp</span><span class="skey">grocerapp.pk</span></div>
</div>
<h2>Platform Details</h2>
<div style="margin:16px 0">
<div class="rf"><span class="rn">alfatah.pk</span><span class="rt">Shopify</span><span class="rd">Uses <code>/search/suggest.json</code> API + <code>/products/{handle}</code> URLs. Full JSON-LD support.</span></div>
<div class="rf"><span class="rn">qne.com.pk</span><span class="rt">Custom</span><span class="rd">WooCommerce-style. CSS fallback selectors configured. HTML search scraping.</span></div>
<div class="rf"><span class="rn">springs.com.pk</span><span class="rt">WooCommerce</span><span class="rd">Standard WooCommerce product URLs (<code>/product/slug</code>). Sitemap-based enumeration.</span></div>
<div class="rf"><span class="rn">vmart.pk</span><span class="rt">Custom</span><span class="rd">Generic search URL template. CSS fallback for title/price/image extraction.</span></div>
<div class="rf"><span class="rn">grocerapp.pk</span><span class="rt">Custom</span><span class="rd">Custom sitemap hints configured. Product URLs match <code>/products/slug-id</code> pattern.</span></div>
</div>
<div class="callout info">
<span class="callout-icon">π‘</span>
<div>To add a new store, add an entry to the <code>STORES</code> list in <code>app.py</code> with <code>base</code>, <code>platform</code>, and optional <code>product_link_patterns</code>, <code>sitemap_hints</code>, and <code>product_fallback</code> CSS selectors.</div>
</div>
</section>
<!-- ββ /search ββ -->
<section id="sec-search" class="section">
<div class="eyebrow">API Reference</div>
<h1 class="page-title"><em>/search</em></h1>
<p class="page-desc">Live product search across all registered stores simultaneously.</p>
<div class="ep open">
<div class="ep-head" onclick="toggleEp(this.parentElement)">
<span class="method GET">GET</span>
<span class="ep-path">/search</span>
<span class="ep-desc">Search all stores</span>
<span class="chevron">βΎ</span>
</div>
<div class="ep-body">
<h3>Query Parameters</h3>
<table class="ptable">
<thead><tr><th>Param</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><span class="pn">q</span><span class="req">required</span></td><td><span class="pt">string</span></td><td class="pd">Product keyword. E.g. <code>"milk"</code>, <code>"bread"</code>, <code>"rice 5kg"</code>.</td></tr>
<tr><td><span class="pn">limit</span><span class="opt">optional</span></td><td><span class="pt">integer</span></td><td class="pd">Max results to return (1β100, default: 40).</td></tr>
</tbody>
</table>
<h3>How it works</h3>
<p>For each store the API uses the most appropriate search strategy: Shopify stores use <code>/search/suggest.json</code>, WooCommerce uses <code>/?s=query&post_type=product</code>, and custom stores use their configured <code>search_url_template</code>. Product page URLs are collected, scraped in parallel (max 5 concurrent), and deduplicated by title+source keeping the cheapest price.</p>
<h3>Response Fields</h3>
<div style="margin:14px 0">
<div class="rf"><span class="rn">query</span><span class="rt">string</span><span class="rd">The search query as received.</span></div>
<div class="rf"><span class="rn">count</span><span class="rt">integer</span><span class="rd">Number of results returned.</span></div>
<div class="rf"><span class="rn">offers</span><span class="rt">array</span><span class="rd">Array of product objects sorted by price ascending.</span></div>
<div class="rf"><span class="rn">offers[].source</span><span class="rt">string</span><span class="rd">Store display name (e.g. <code>"Al-Fatah"</code>).</span></div>
<div class="rf"><span class="rn">offers[].title</span><span class="rt">string</span><span class="rd">Product name.</span></div>
<div class="rf"><span class="rn">offers[].price</span><span class="rt">float</span><span class="rd">Price as a number.</span></div>
<div class="rf"><span class="rn">offers[].currency</span><span class="rt">string</span><span class="rd">Currency code, default <code>"PKR"</code>.</span></div>
<div class="rf"><span class="rn">offers[].url</span><span class="rt">string</span><span class="rd">Direct product page URL.</span></div>
<div class="rf"><span class="rn">offers[].image</span><span class="rt">string?</span><span class="rd">Product image URL. May be null if not found.</span></div>
<div class="rf"><span class="rn">offers[].in_stock</span><span class="rt">bool?</span><span class="rd">Stock status from JSON-LD availability. Null if not available.</span></div>
</div>
</div>
</div>
</section>
<!-- ββ /crawl ββ -->
<section id="sec-crawl" class="section">
<div class="eyebrow">API Reference</div>
<h1 class="page-title"><em>/crawl</em></h1>
<p class="page-desc">Enumerate all product URLs for a store and optionally scrape full details. Supports pagination via cursor.</p>
<div class="ep open">
<div class="ep-head" onclick="toggleEp(this.parentElement)">
<span class="method GET">GET</span>
<span class="ep-path">/crawl</span>
<span class="ep-desc">Full store enumeration</span>
<span class="chevron">βΎ</span>
</div>
<div class="ep-body">
<h3>Query Parameters</h3>
<table class="ptable">
<thead><tr><th>Param</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
<tr><td><span class="pn">store</span><span class="req">required</span></td><td><span class="pt">string</span></td><td class="pd">Domain key, e.g. <code>"alfatah.pk"</code>. Must match a registered store.</td></tr>
<tr><td><span class="pn">limit</span><span class="opt">optional</span></td><td><span class="pt">integer</span></td><td class="pd">Items per call (1β500, default: 100).</td></tr>
<tr><td><span class="pn">cursor</span><span class="opt">optional</span></td><td><span class="pt">integer</span></td><td class="pd">Resume offset. Pass <code>next_cursor</code> from previous response. Default: 0.</td></tr>
<tr><td><span class="pn">mode</span><span class="opt">optional</span></td><td><span class="pt">string</span></td><td class="pd"><code>"urls"</code> β enumerate only (fast). <code>"full"</code> β enumerate + scrape prices. Default: <code>"full"</code>.</td></tr>
<tr><td><span class="pn">use_js</span><span class="opt">optional</span></td><td><span class="pt">0 | 1</span></td><td class="pd">Override Playwright JS rendering for this request.</td></tr>
</tbody>
</table>
<h3>Response Fields</h3>
<div style="margin:14px 0">
<div class="rf"><span class="rn">store</span><span class="rt">string</span><span class="rd">Store domain key as provided.</span></div>
<div class="rf"><span class="rn">count</span><span class="rt">integer</span><span class="rd">Number of items in this batch.</span></div>
<div class="rf"><span class="rn">items</span><span class="rt">array</span><span class="rd">Array of product objects (same structure as /search offers). In <code>urls</code> mode: <code>[{url: string}]</code>.</span></div>
<div class="rf"><span class="rn">next_cursor</span><span class="rt">int?</span><span class="rd">Pass as <code>cursor</code> in next call to get next batch. <code>null</code> when all items fetched.</span></div>
<div class="rf"><span class="rn">total_urls</span><span class="rt">integer</span><span class="rd">Total product URLs found in store (before pagination).</span></div>
<div class="rf"><span class="rn">enumeration</span><span class="rt">string</span><span class="rd"><code>"sitemap_or_bfs"</code> or <code>"none"</code>.</span></div>
<div class="rf"><span class="rn">js_fallback</span><span class="rt">bool</span><span class="rd">Whether Playwright JS rendering was active.</span></div>
</div>
<h3>Paginating a full store</h3>
<div class="code-wrap">
<div class="code-lang">javascript</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>let cursor = 0;
const allProducts = [];
while (cursor !== null) {
const res = await fetch(
`/crawl?store=alfatah.pk&mode=full&limit=100&cursor=${cursor}`
);
const data = await res.json();
allProducts.push(...data.items);
cursor = data.next_cursor; // null when done
console.log(`Fetched ${allProducts.length} / ${data.total_urls}`);
}
// Save allProducts to Firestore</pre>
</div>
</div>
</div>
</section>
<!-- ββ /health ββ -->
<section id="sec-health" class="section">
<div class="eyebrow">API Reference</div>
<h1 class="page-title"><em>/health</em></h1>
<div class="ep open">
<div class="ep-head" onclick="toggleEp(this.parentElement)">
<span class="method GET">GET</span>
<span class="ep-path">/health</span>
<span class="ep-desc">Status check</span>
<span class="chevron">βΎ</span>
</div>
<div class="ep-body">
<p>Simple liveness check. Returns immediately with no external calls.</p>
<div class="code-wrap">
<div class="code-lang">json</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>{ "ok": true }</pre>
</div>
</div>
</div>
</section>
<!-- ββ SITEMAP ββ -->
<section id="sec-sitemap" class="section">
<div class="eyebrow">Concepts</div>
<h1 class="page-title">Sitemap <em>& BFS</em></h1>
<p class="page-desc">How the API discovers every product URL in a store.</p>
<h2>Discovery Order</h2>
<div class="steps">
<div class="step"><div class="step-n">1</div><div class="step-c"><h4>robots.txt</h4><p>Fetches <code>/robots.txt</code> and parses any <code>Sitemap:</code> directives.</p></div></div>
<div class="step"><div class="step-n">2</div><div class="step-c"><h4>Store-specific hints</h4><p>Each store entry can declare <code>sitemap_hints</code> β custom sitemap URLs tested before defaults.</p></div></div>
<div class="step"><div class="step-n">3</div><div class="step-c"><h4>Default paths</h4><p>Tries <code>/sitemap.xml</code>, <code>/sitemap_index.xml</code>, <code>/sitemap-index.xml</code> in order.</p></div></div>
<div class="step"><div class="step-n">4</div><div class="step-c"><h4>BFS fallback</h4><p>If no sitemap yields product URLs, a bounded BFS crawl (max 50 pages, max 8000 URLs) follows category/listing links to find products.</p></div></div>
</div>
<div class="callout info">
<span class="callout-icon">βΉοΈ</span>
<div>Sitemap index files (containing child <code>.xml</code> URLs) are followed recursively up to 5000 nodes. All discovered URLs are filtered to the store's domain and matched against <code>product_link_patterns</code>.</div>
</div>
</section>
<!-- ββ PARSING ββ -->
<section id="sec-parsing" class="section">
<div class="eyebrow">Concepts</div>
<h1 class="page-title">Product <em>Parsing</em></h1>
<p class="page-desc">How price, title and image are extracted from each product page.</p>
<h2>Extraction Order</h2>
<div class="steps">
<div class="step"><div class="step-n">1</div><div class="step-c"><h4>JSON-LD structured data</h4><p>Parses all <code><script type="application/ld+json"></code> blocks looking for <code>@type: "Product"</code>. Handles nested offers, aggregateOffer, and priceSpecification.</p></div></div>
<div class="step"><div class="step-n">2</div><div class="step-c"><h4>CSS selector fallback</h4><p>If no JSON-LD Product found, uses platform-specific CSS selectors for title, price, and image. Custom stores can define their own <code>product_fallback</code> selectors.</p></div></div>
<div class="step"><div class="step-n">3</div><div class="step-c"><h4>OG image fallback</h4><p>If no product image found via CSS, checks <code><meta property="og:image"></code> and <code><link rel="image_src"></code>.</p></div></div>
<div class="step"><div class="step-n">4</div><div class="step-c"><h4>Service filter</h4><p>Titles containing service keywords (installation, repair, cleaning, etc.) are discarded β only physical products are returned.</p></div></div>
</div>
<h3>Price Cleaning</h3>
<p>Raw price strings like <code>"Rs. 1,285.00"</code> or <code>"PKR 285"</code> are normalized using regex extraction of the first numeric value, stripping commas and currency symbols.</p>
</section>
<!-- ββ PAGINATION ββ -->
<section id="sec-pagination" class="section">
<div class="eyebrow">Concepts</div>
<h1 class="page-title"><em>Pagination</em></h1>
<p class="page-desc">The /crawl endpoint is designed for paginated calls to avoid HuggingFace Spaces timeouts.</p>
<div class="callout warn">
<span class="callout-icon">β οΈ</span>
<div><strong>HF Spaces timeout:</strong> Free tier Spaces have a 60s request timeout. Keep <code>limit</code> at 50β100 for <code>mode=full</code> (scraping) and up to 500 for <code>mode=urls</code> (enumeration only).</div>
</div>
<h3>Pagination Flow</h3>
<div class="code-wrap">
<div class="code-lang">python</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>import requests, time
BASE = "https://frnklnwrld-savenest-api.hf.space"
cursor = 0
all_items = []
while cursor is not None:
res = requests.get(f"{BASE}/crawl", params={
"store": "alfatah.pk",
"mode": "full",
"limit": 50,
"cursor": cursor
})
data = res.json()
all_items.extend(data["items"])
cursor = data["next_cursor"]
print(f"Got {len(all_items)} / {data['total_urls']}")
time.sleep(1) # be polite
print(f"Done. Total: {len(all_items)} products")</pre>
</div>
</section>
<!-- ββ PLAYWRIGHT ββ -->
<section id="sec-playwright" class="section">
<div class="eyebrow">Concepts</div>
<h1 class="page-title">JS <em>Rendering</em></h1>
<p class="page-desc">Optional Playwright (Firefox) rendering for JavaScript-heavy sites.</p>
<p>Some stores (particularly Daraz) render product data entirely via JavaScript β a plain HTTP fetch returns an empty shell. The API includes optional Playwright Firefox rendering to handle these cases.</p>
<h2>How to enable</h2>
<ul class="flist">
<li>Set <code>USE_PLAYWRIGHT=1</code> in your HF Space environment variables (enabled by default)</li>
<li>Per-request override: add <code>use_js=1</code> or <code>use_js=0</code> to any <code>/crawl</code> request</li>
<li>Daraz.pk is automatically detected and uses JS rendering when Playwright is enabled</li>
</ul>
<div class="callout warn">
<span class="callout-icon">β οΈ</span>
<div><strong>Performance:</strong> JS rendering adds ~3-5s per page. Use sparingly and only for stores that require it. Plain HTTP is always tried first; Playwright is only invoked as a fallback when the plain fetch returns no usable HTML.</div>
</div>
<h3>Playwright install (for local dev)</h3>
<div class="code-wrap">
<div class="code-lang">bash</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>pip install playwright
playwright install firefox</pre>
</div>
</section>
<!-- ββ FLUTTER ββ -->
<section id="sec-flutter" class="section">
<div class="eyebrow">Integration</div>
<h1 class="page-title">Flutter <em>Guide</em></h1>
<p class="page-desc">Complete Dart service class and widget patterns for the SaveNest API.</p>
<h3>Service Class</h3>
<div class="code-wrap">
<div class="code-lang">dart</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>import 'dart:convert';
import 'package:http/http.dart' as http;
class SaveNestService {
static const String base = 'https://frnklnwrld-savenest-api.hf.space';
/// Search for a product across all stores
Future<List<Map>> search(String query, {int limit = 20}) async {
final uri = Uri.parse('$base/search')
.replace(queryParameters: {'q': query, 'limit': '$limit'});
final res = await http.get(uri);
if (res.statusCode != 200) throw Exception('Search failed: ${res.statusCode}');
final data = jsonDecode(res.body);
return List<Map>.from(data['offers']);
}
/// Get product URLs from a store (paginated)
Future<Map> crawlUrls(String store, {int limit = 100, int cursor = 0}) async {
final uri = Uri.parse('$base/crawl').replace(queryParameters: {
'store': store, 'mode': 'urls',
'limit': '$limit', 'cursor': '$cursor'
});
final res = await http.get(uri);
if (res.statusCode != 200) throw Exception('Crawl failed');
return jsonDecode(res.body) as Map;
}
}</pre>
</div>
<h3>Price Comparison Widget</h3>
<div class="code-wrap">
<div class="code-lang">dart</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>final service = SaveNestService();
final results = await service.search('olpers milk');
// Sort by price (already sorted, but just in case)
results.sort((a, b) => (a['price'] ?? 999999).compareTo(b['price'] ?? 999999));
// Render
ListView.builder(
itemCount: results.length,
itemBuilder: (ctx, i) {
final item = results[i];
return ListTile(
leading: item['image'] != null
? Image.network(item['image'], width: 48, errorBuilder: (_,__,___) => Icon(Icons.shopping_bag))
: Icon(Icons.shopping_bag),
title: Text(item['title']),
subtitle: Text(item['source']),
trailing: Text(
'PKR ${item['price']}',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.green),
),
onTap: () => launchUrl(Uri.parse(item['url'])),
);
}
)</pre>
</div>
</section>
<!-- ββ ERRORS ββ -->
<section id="sec-errors" class="section">
<div class="eyebrow">Integration</div>
<h1 class="page-title">Error <em>Handling</em></h1>
<div class="divider"></div>
<div class="er"><span class="ec e400">400</span><div><strong style="color:var(--text)">Bad Request</strong><br><span style="color:var(--text2);font-size:.82rem">Unknown store key in <code>/crawl</code>, or missing <code>q</code> param in <code>/search</code>. Check the <code>detail</code> field in the response.</span></div></div>
<div class="er"><span class="ec e422">422</span><div><strong style="color:var(--text)">Validation Error</strong><br><span style="color:var(--text2);font-size:.82rem">Invalid parameter type or out-of-range value (e.g. <code>limit=600</code>). FastAPI returns detailed validation errors.</span></div></div>
<div class="er"><span class="ec e500">500</span><div><strong style="color:var(--text)">Server Error</strong><br><span style="color:var(--text2);font-size:.82rem">Unexpected scraping error. Common causes: store changed HTML structure, Playwright unavailable, network timeout. Check HF Space logs.</span></div></div>
<div class="divider"></div>
<h3>Recommended retry pattern</h3>
<div class="code-wrap">
<div class="code-lang">javascript</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>async function safeSearch(query, retries = 2) {
for (let i = 0; i <= retries; i++) {
try {
const res = await fetch(`/search?q=${encodeURIComponent(query)}&limit=20`);
if (res.ok) return await res.json();
if (res.status === 400) throw new Error('Bad query');
// 500 β retry
if (i < retries) await new Promise(r => setTimeout(r, 2000 * (i + 1)));
} catch(e) {
if (i === retries) throw e;
}
}
}</pre>
</div>
</section>
<!-- ββ UPWORK ββ -->
<section id="sec-upwork" class="section">
<div class="eyebrow">Upwork Proposal Materials</div>
<h1 class="page-title">Proposal <em>Kit</em></h1>
<p class="page-desc">Ready-to-copy templates for hiring on Upwork β Flutter developers, backend engineers, and data engineers.</p>
<div class="proposal-box">
<h3>Flutter Developer β Price Comparison App</h3>
<p style="color:var(--text2);font-size:.88rem;margin-top:10px">Complete job post for a Flutter dev to build the mobile UI.</p>
</div>
<div class="code-wrap">
<div class="code-lang">Upwork Job Post β Flutter Developer</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>Title: Flutter Developer β Grocery Price Comparison App (Pakistan)
We're building SaveNest, a Flutter app that shows real-time grocery prices
across Pakistan's top stores. The backend API is live and production-ready.
We need an experienced Flutter developer to build the mobile UI.
=== LIVE BACKEND ===
API: https://frnklnwrld-savenest-api.hf.space
Swagger: https://frnklnwrld-savenest-api.hf.space/docs
Docs: https://frnklnwrld-savenest-api.hf.space/documentation
=== BACKEND CAPABILITIES ===
β’ GET /search?q=milk β search all 5 stores, returns price-sorted results
β’ GET /crawl?store=alfatah.pk β enumerate + scrape entire store (paginated)
β’ Stores: Al-Fatah, QnE, Springs, Vmart, GrocerApp
β’ Returns: title, price (PKR), image URL, product URL, in_stock status
=== YOUR RESPONSIBILITIES ===
1. Home screen: search bar + recent searches
2. Results screen: price comparison cards sorted cheapest first
- Show: store name, product title, price, image, in-stock badge
- "View Product" button opens product URL in browser
3. Store browser: crawl mode screen with infinite scroll (paginated cursor)
4. Price history chart (local storage, compare prices over time)
5. Loading/error states with retry UX
6. State management: Riverpod or Provider
7. Offline cache: last search results
=== API INTEGRATION NOTES ===
β’ /search returns immediately, ~5-15s depending on store response times
β’ /crawl supports cursor pagination: pass next_cursor back as cursor param
β’ next_cursor is null when all products fetched
β’ Images may be null β handle gracefully with placeholder
=== REQUIREMENTS ===
β’ 3+ years Flutter experience
β’ REST API integration (Dio or http package)
β’ Experience with pagination / infinite scroll
β’ Price/comparison app UI portfolio preferred
Please share 2-3 examples of e-commerce or comparison apps you've built.
Budget: [your budget] | Timeline: 2-3 weeks</pre>
</div>
<div class="proposal-box">
<h3>Backend / Scraping Engineer</h3>
</div>
<div class="code-wrap">
<div class="code-lang">Upwork Job Post β Backend Engineer</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>Title: Python/FastAPI Developer β Improve Grocery Scraping API
We have a working FastAPI web scraper for Pakistan grocery stores.
Core is functional. Need improvements and more store coverage.
=== CURRENT STACK ===
β’ FastAPI + Python 3.10, hosted on HuggingFace Spaces
β’ Live: https://frnklnwrld-savenest-api.hf.space
β’ Docs: https://frnklnwrld-savenest-api.hf.space/documentation
=== CURRENT FEATURES ===
β’ Sitemap enumeration (Shopify, WooCommerce, custom)
β’ BFS fallback crawler (bounded, 50-page cap)
β’ JSON-LD product parsing + CSS selector fallback
β’ Playwright (Firefox) for JS-heavy stores
β’ In-memory TTL cache with per-request isolation
β’ 5 stores: alfatah.pk, qne.com.pk, springs.com.pk, vmart.pk, grocerapp.pk
=== TASKS ===
1. Add 5 more Pakistani grocery stores (daraz.pk groceries, imtiaz.pk,
metro.pk, carrefour.pk, naheed.pk) with correct platform configs
2. Improve Daraz scraping β currently blocked, needs better JS/header handling
3. Add Redis caching (replace in-memory) for multi-worker deployment
4. Add /products/{store} endpoint to list cached products without re-scraping
5. Improve price parsing for edge cases (sale prices, per-kg pricing)
6. Add async Playwright support (currently sync fallback, blocks event loop)
7. Write pytest suite for parsing functions
=== REQUIREMENTS ===
β’ Strong Python async (asyncio, httpx)
β’ Web scraping experience (BeautifulSoup, Playwright, Selenium)
β’ Understanding of JSON-LD / schema.org product markup
β’ Experience scraping Pakistani/South Asian e-commerce sites preferred
GitHub or scraping portfolio required.
Budget: [your budget] | Timeline: 1-2 weeks</pre>
</div>
<div class="proposal-box">
<h3>Technical Spec Sheet</h3>
<p style="color:var(--text2);font-size:.88rem;margin-top:10px">Share with any developer you're interviewing.</p>
</div>
<div class="code-wrap">
<div class="code-lang">Technical Spec</div>
<button class="copy-btn" onclick="doCopy(this)">copy</button>
<pre>=== SAVENEST CRAWLER API β TECHNICAL SPEC ===
LIVE
API: https://frnklnwrld-savenest-api.hf.space
Docs: https://frnklnwrld-savenest-api.hf.space/documentation
Swagger: https://frnklnwrld-savenest-api.hf.space/docs
ENDPOINTS
GET /search?q={query}&limit={n}
β searches all stores, returns deduplicated price-sorted offers
GET /crawl?store={domain}&mode={urls|full}&limit={n}&cursor={n}
β enumerates entire store, paginates via cursor
GET /health
β {"ok": true}
RESPONSE STRUCTURE (offers array)
source: string β store display name
title: string β product name
price: float β numeric price
currency: string β "PKR"
url: string β product page URL
image: string? β image URL (may be null)
in_stock: bool? β availability (may be null)
CRAWL PAGINATION
Pass next_cursor from response as cursor in next call.
next_cursor is null when all products fetched.
SUPPORTED STORES
alfatah.pk β Shopify (suggest.json + JSON-LD)
qne.com.pk β Custom (HTML search + CSS fallback)
springs.com.pk β WooCommerce (sitemap + JSON-LD)
vmart.pk β Custom (search template + CSS)
grocerapp.pk β Custom (sitemap hints + CSS)
ENV VARS (HF Space Settings)
USE_PLAYWRIGHT=1 enable Firefox JS rendering
PER_HOST_GAP=2 politeness delay seconds
REQUEST_TIMEOUT=30 HTTP timeout
MAX_CONCURRENCY=5 parallel fetches
CRAWL_LIMIT_CAP=500 max items per /crawl call
TECH STACK
Python 3.10, FastAPI, httpx, BeautifulSoup4
rapidfuzz, playwright (optional), uvicorn
CORS: open (*)</pre>
</div>
<div class="tags">
<span class="tag">FastAPI</span>
<span class="tag">Python</span>
<span class="tag">Web Scraping</span>
<span class="tag">Flutter</span>
<span class="tag">Playwright</span>
<span class="tag">BeautifulSoup</span>
<span class="tag">JSON-LD</span>
<span class="tag">Pakistan E-commerce</span>
<span class="tag">Price Comparison</span>
<span class="tag">HuggingFace</span>
</div>
</section>
</div>
</main>
<script>
const SECS = {
overview: {el:'sec-overview', label:'Overview'},
quickstart: {el:'sec-quickstart', label:'Quick Start'},
stores: {el:'sec-stores', label:'Supported Stores'},
search: {el:'sec-search', label:'/search'},
crawl: {el:'sec-crawl', label:'/crawl'},
health: {el:'sec-health', label:'/health'},
sitemap: {el:'sec-sitemap', label:'Sitemap & BFS'},
parsing: {el:'sec-parsing', label:'Product Parsing'},
pagination: {el:'sec-pagination', label:'Pagination'},
playwright: {el:'sec-playwright', label:'JS Rendering'},
flutter: {el:'sec-flutter', label:'Flutter Guide'},
errors: {el:'sec-errors', label:'Error Handling'},
upwork: {el:'sec-upwork', label:'Upwork Proposal Kit'},
};
function show(key) {
const def = SECS[key]; if (!def) return;
Object.values(SECS).forEach(s => {
const el = document.getElementById(s.el);
if (el) el.classList.remove('active');
});
const t = document.getElementById(def.el);
if (t) t.classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
if (event && event.currentTarget) event.currentTarget.classList.add('active');
document.getElementById('bc').textContent = def.label;
document.querySelector('.main').scrollTo({top:0, behavior:'smooth'});
document.querySelector('.sidebar').classList.remove('open');
}
function toggleEp(card) { card.classList.toggle('open'); }
function doCopy(btn) {
const pre = btn.nextElementSibling;
navigator.clipboard.writeText((pre.innerText || '').trim()).then(() => {
btn.textContent = '\u2713 copied';
setTimeout(() => btn.textContent = 'copy', 2000);
}).catch(() => {});
}
function searchNav(q) {
const v = (q || '').toLowerCase().trim();
document.querySelectorAll('.nav-item').forEach(n => {
n.style.display = (!v || n.textContent.toLowerCase().includes(v)) ? '' : 'none';
});
}
</script>
</body>
</html> |